chore: modal and dropdown improvement (#3332)

* dev: dropdown key down custom hook added

* chore: plane ui dropdowns updated

* chore: cycle and module tab index added in modals

* chore: view and page tab index added in modals

* chore: issue modal tab indexing added

* chore: project modal tab indexing added

* fix: build fix

* build-error: build error in pages new structure and reverted back to old page structure

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2024-01-10 12:21:24 +05:30 committed by GitHub
parent 08e5f2b156
commit 8b884ab681
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1300 additions and 717 deletions

View File

@ -2,6 +2,9 @@ import * as React from "react";
// react-poppper // react-poppper
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// headless ui // headless ui
import { Menu } from "@headlessui/react"; import { Menu } from "@headlessui/react";
// type // type
@ -27,16 +30,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
verticalEllipsis = false, verticalEllipsis = false,
width = "auto", width = "auto",
menuButtonOnClick, menuButtonOnClick,
tabIndex,
} = props; } = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | 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 { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto", placement: placement ?? "auto",
}); });
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Menu as="div" className={`relative w-min text-left ${className}`}> <Menu
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={`relative w-min text-left ${className}`}
onKeyDown={handleKeyDown}
>
{({ open }) => ( {({ open }) => (
<> <>
{customButton ? ( {customButton ? (
@ -44,7 +66,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={menuButtonOnClick} onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
className={customButtonClassName} className={customButtonClassName}
> >
{customButton} {customButton}
@ -57,7 +82,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={menuButtonOnClick} onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
disabled={disabled} disabled={disabled}
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${ className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
@ -78,6 +106,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
> >
{label} {label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />} {!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@ -86,26 +118,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
)} )}
</> </>
)} )}
<Menu.Items className="fixed z-10"> {isOpen && (
<div <Menu.Items className="fixed z-10" static>
className={`my-1 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-custom-shadow-rg focus:outline-none ${ <div
maxHeight === "lg" className={`my-1 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-custom-shadow-rg focus:outline-none ${
? "max-h-60" maxHeight === "lg"
: maxHeight === "md" ? "max-h-60"
? "max-h-48" : maxHeight === "md"
: maxHeight === "rg" ? "max-h-48"
? "max-h-36" : maxHeight === "rg"
: maxHeight === "sm" ? "max-h-36"
? "max-h-28" : maxHeight === "sm"
: "" ? "max-h-28"
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`} : ""
ref={setPopperElement} } ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
style={styles.popper} ref={setPopperElement}
{...attributes.popper} style={styles.popper}
> {...attributes.popper}
{children} >
</div> {children}
</Menu.Items> </div>
</Menu.Items>
)}
</> </>
)} )}
</Menu> </Menu>

View File

@ -1,7 +1,10 @@
import React, { useState } from "react"; import React, { useRef, useState } from "react";
// react-popper // react-popper
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// headless ui // headless ui
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// icons // icons
@ -29,11 +32,15 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
optionsClassName = "", optionsClassName = "",
value, value,
width = "auto", width = "auto",
tabIndex,
} = props; } = props;
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | 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, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start", placement: placement ?? "bottom-start",
@ -50,8 +57,23 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
if (multiple) comboboxProps.multiple = true; if (multiple) comboboxProps.multiple = true;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox as="div" className={`relative flex-shrink-0 text-left ${className}`} {...comboboxProps}> <Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={`relative flex-shrink-0 text-left ${className}`}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
{({ open }: { open: boolean }) => { {({ open }: { open: boolean }) => {
if (open && onOpen) onOpen(); if (open && onOpen) onOpen();
@ -67,6 +89,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`} } ${customButtonClassName}`}
onClick={openDropdown}
> >
{customButton} {customButton}
</button> </button>
@ -83,86 +106,89 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={openDropdown}
> >
{label} {label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />} {!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button> </button>
</Combobox.Button> </Combobox.Button>
)} )}
<Combobox.Options as={React.Fragment}> {isOpen && (
<div <Combobox.Options as={React.Fragment} static>
className={`z-10 my-1 min-w-[10rem] rounded-md border border-custom-border-300 bg-custom-background-90 p-2 text-xs shadow-custom-shadow-rg focus:outline-none ${
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
} ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3 w-3 text-custom-text-200" />
<Combobox.Input
className="w-full bg-transparent px-2 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="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div <div
className={`mt-2 space-y-1 ${ className={`z-10 my-1 min-w-[10rem] rounded-md border border-custom-border-300 bg-custom-background-90 p-2 text-xs shadow-custom-shadow-rg focus:outline-none ${
maxHeight === "lg" width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
? "max-h-60" } ${optionsClassName}`}
: maxHeight === "md" ref={setPopperElement}
? "max-h-48" style={styles.popper}
: maxHeight === "rg" {...attributes.popper}
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} overflow-y-scroll`}
> >
{filteredOptions ? ( <div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
filteredOptions.length > 0 ? ( <Search className="h-3 w-3 text-custom-text-200" />
filteredOptions.map((option) => ( <Combobox.Input
<Combobox.Option className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
key={option.value} value={query}
value={option.value} onChange={(e) => setQuery(e.target.value)}
className={({ active, selected }) => placeholder="Type to search..."
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ displayValue={(assigned: any) => assigned?.name}
active || selected ? "bg-custom-background-80" : "" />
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` </div>
} <div
> className={`mt-2 space-y-1 ${
{({ active, selected }) => ( maxHeight === "lg"
<> ? "max-h-60"
{option.content} : maxHeight === "md"
{multiple ? ( ? "max-h-48"
<div : maxHeight === "rg"
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${ ? "max-h-36"
active || selected ? "opacity-100" : "opacity-0" : maxHeight === "sm"
}`} ? "max-h-28"
> : ""
} overflow-y-scroll`}
>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
active || selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ active, selected }) => (
<>
{option.content}
{multiple ? (
<div
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${
active || selected ? "opacity-100" : "opacity-0"
}`}
>
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
</div>
) : (
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} /> <Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
</div> )}
) : ( </>
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} /> )}
)} </Combobox.Option>
</> ))
)} ) : (
</Combobox.Option> <span className="flex items-center gap-2 p-1">
)) <p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : ( ) : (
<span className="flex items-center gap-2 p-1"> <p className="text-center text-custom-text-200">Loading...</p>
<p className="text-left text-custom-text-200 ">No matching results</p> )}
</span> </div>
) {footerOption}
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div> </div>
{footerOption} </Combobox.Options>
</div> )}
</Combobox.Options>
</> </>
); );
}} }}

View File

@ -1,7 +1,10 @@
import React, { useState } from "react"; import React, { useRef, useState } from "react";
// react-popper // react-popper
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// headless ui // headless ui
import { Listbox } from "@headlessui/react"; import { Listbox } from "@headlessui/react";
// icons // icons
@ -26,20 +29,36 @@ const CustomSelect = (props: ICustomSelectProps) => {
optionsClassName = "", optionsClassName = "",
value, value,
width = "auto", width = "auto",
tabIndex,
} = props; } = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | 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, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start", placement: placement ?? "bottom-start",
}); });
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Listbox <Listbox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value} value={value}
onChange={onChange} onChange={onChange}
className={`relative flex-shrink-0 text-left ${className}`} className={`relative flex-shrink-0 text-left ${className}`}
onKeyDown={handleKeyDown}
disabled={disabled} disabled={disabled}
> >
<> <>
@ -51,6 +70,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
className={`flex items-center justify-between gap-1 text-xs ${ className={`flex items-center justify-between gap-1 text-xs ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`} } ${customButtonClassName}`}
onClick={openDropdown}
> >
{customButton} {customButton}
</button> </button>
@ -65,6 +85,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
} ${ } ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={openDropdown}
> >
{label} {label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />} {!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
@ -72,26 +93,28 @@ const CustomSelect = (props: ICustomSelectProps) => {
</Listbox.Button> </Listbox.Button>
)} )}
</> </>
<Listbox.Options> {isOpen && (
<div <Listbox.Options static>
className={`z-10 my-1 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none ${ <div
maxHeight === "lg" className={`z-10 my-1 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none ${
? "max-h-60" maxHeight === "lg"
: maxHeight === "md" ? "max-h-60"
? "max-h-48" : maxHeight === "md"
: maxHeight === "rg" ? "max-h-48"
? "max-h-36" : maxHeight === "rg"
: maxHeight === "sm" ? "max-h-36"
? "max-h-28" : maxHeight === "sm"
: "" ? "max-h-28"
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`} : ""
ref={setPopperElement} } ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
style={styles.popper} ref={setPopperElement}
{...attributes.popper} style={styles.popper}
> {...attributes.popper}
<div className="space-y-1 p-2">{children}</div> >
</div> <div className="space-y-1 p-2">{children}</div>
</Listbox.Options> </div>
</Listbox.Options>
)}
</Listbox> </Listbox>
); );
}; };

View File

@ -15,6 +15,7 @@ export interface IDropdownProps {
optionsClassName?: string; optionsClassName?: string;
width?: "auto" | string; width?: "auto" | string;
placement?: Placement; placement?: Placement;
tabIndex?: number;
} }
export interface ICustomMenuDropdownProps extends IDropdownProps { export interface ICustomMenuDropdownProps extends IDropdownProps {

View File

@ -0,0 +1,24 @@
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) {
onOpen();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
onClose();
}
},
[isOpen, onOpen, onClose]
);
return handleKeyDown;
};

View File

@ -0,0 +1,19 @@
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
});
};
export default useOutsideClickDetector;

View File

@ -4,10 +4,11 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Popover } from "@headlessui/react";
import { Control, Controller } from "react-hook-form"; import { Control, Controller } from "react-hook-form";
// hooks // hooks
import { useApplication, useWorkspace } from "hooks/store"; import { useApplication, useWorkspace } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// services // services
import { FileService } from "services/file.service"; import { FileService } from "services/file.service";
// hooks // hooks
@ -38,13 +39,14 @@ type Props = {
control: Control<any>; control: Control<any>;
onChange: (data: string) => void; onChange: (data: string) => void;
disabled?: boolean; disabled?: boolean;
tabIndex?: number;
}; };
// services // services
const fileService = new FileService(); const fileService = new FileService();
export const ImagePickerPopover: React.FC<Props> = observer((props) => { export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const { label, value, control, onChange, disabled = false } = props; const { label, value, control, onChange, disabled = false, tabIndex } = props;
// states // states
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false);
@ -128,27 +130,27 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
onChange(unsplashImages[0].urls.regular); onChange(unsplashImages[0].urls.regular);
}, [value, onChange, unsplashImages]); }, [value, onChange, unsplashImages]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); const openDropdown = () => setIsOpen(true);
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(ref, closeDropdown);
return ( return (
<Popover className="relative z-[2]" ref={ref}> <Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
<Popover.Button <Popover.Button
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100" className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)} onClick={openDropdown}
disabled={disabled} disabled={disabled}
> >
{label} {label}
</Popover.Button> </Popover.Button>
<Transition
show={isOpen} {isOpen && (
enter="transition ease-out duration-100" <Popover.Panel
enterFrom="transform opacity-0 scale-95" className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm"
enterTo="transform opacity-100 scale-100" static
leave="transition ease-in duration-75" >
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm">
<div <div
ref={imagePickerRef} ref={imagePickerRef}
className="flex h-96 w-80 flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl md:h-[28rem] md:w-[36rem]" className="flex h-96 w-80 flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl md:h-[28rem] md:w-[36rem]"
@ -349,7 +351,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
</Tab.Group> </Tab.Group>
</div> </div>
</Popover.Panel> </Popover.Panel>
</Transition> )}
</Popover> </Popover>
); );
}); });

View File

@ -59,6 +59,7 @@ export const CycleForm: React.FC<Props> = (props) => {
setActiveProject(val); setActiveProject(val);
}} }}
buttonVariant="background-with-text" buttonVariant="background-with-text"
tabIndex={7}
/> />
)} )}
/> />
@ -89,6 +90,7 @@ export const CycleForm: React.FC<Props> = (props) => {
inputSize="md" inputSize="md"
onChange={onChange} onChange={onChange}
hasError={Boolean(errors?.name)} hasError={Boolean(errors?.name)}
tabIndex={1}
/> />
)} )}
/> />
@ -106,6 +108,7 @@ export const CycleForm: React.FC<Props> = (props) => {
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}
value={value} value={value}
onChange={onChange} onChange={onChange}
tabIndex={2}
/> />
)} )}
/> />
@ -124,6 +127,7 @@ export const CycleForm: React.FC<Props> = (props) => {
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
tabIndex={3}
/> />
</div> </div>
)} )}
@ -140,6 +144,7 @@ export const CycleForm: React.FC<Props> = (props) => {
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="End date" placeholder="End date"
minDate={minDate} minDate={minDate}
tabIndex={4}
/> />
</div> </div>
)} )}
@ -149,10 +154,10 @@ export const CycleForm: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 "> <div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
<Button variant="neutral-primary" size="sm" onClick={handleClose}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={5}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={6}>
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"} {data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Fragment, ReactNode, useEffect, useState } from "react"; import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
// hooks // hooks
import { useApplication, useCycle } from "hooks/store"; import { useApplication, useCycle } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons // icons
import { ContrastIcon } from "@plane/ui"; import { ContrastIcon } from "@plane/ui";
// helpers // helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement; placement?: Placement;
projectId: string; projectId: string;
value: string | null; value: string | null;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -104,9 +107,13 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
placement, placement,
projectId, projectId,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -166,15 +173,26 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const selectedCycle = value ? getCycleById(value) : null; const selectedCycle = value ? getCycleById(value) : null;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -182,6 +200,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -197,6 +216,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -241,53 +261,55 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </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">No matches found</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -1,9 +1,12 @@
import React, { useState } from "react"; import React, { useRef, useState } from "react";
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react"; import { CalendarDays, X } from "lucide-react";
// import "react-datepicker/dist/react-datepicker.css"; // import "react-datepicker/dist/react-datepicker.css";
// hooks
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// helpers // helpers
import { renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
@ -25,6 +28,7 @@ type Props = {
placement?: Placement; placement?: Placement;
value: Date | string | null; value: Date | string | null;
closeOnSelect?: boolean; closeOnSelect?: boolean;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -124,7 +128,11 @@ export const DateDropdown: React.FC<Props> = (props) => {
placement, placement,
value, value,
closeOnSelect = true, closeOnSelect = true,
tabIndex,
} = props; } = props;
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -143,8 +151,16 @@ export const DateDropdown: React.FC<Props> = (props) => {
const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== ""; const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== "";
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Popover className="h-full flex-shrink-0"> <Popover ref={dropdownRef} tabIndex={tabIndex} className="h-full flex-shrink-0" onKeyDown={handleKeyDown}>
{({ close }) => ( {({ close }) => (
<> <>
<Popover.Button as={React.Fragment}> <Popover.Button as={React.Fragment}>
@ -159,6 +175,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -220,22 +237,24 @@ export const DateDropdown: React.FC<Props> = (props) => {
) : null} ) : null}
</button> </button>
</Popover.Button> </Popover.Button>
<Popover.Panel className="fixed z-10"> {isOpen && (
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}> <Popover.Panel className="fixed z-10" static>
<DatePicker <div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
selected={value ? new Date(value) : null} <DatePicker
onChange={(val) => { selected={value ? new Date(value) : null}
onChange(val); onChange={(val) => {
if (closeOnSelect) close(); onChange(val);
}} if (closeOnSelect) close();
dateFormat="dd-MM-yyyy" }}
minDate={minDate} dateFormat="dd-MM-yyyy"
maxDate={maxDate} minDate={minDate}
calendarClassName="shadow-custom-shadow-rg rounded" maxDate={maxDate}
inline calendarClassName="shadow-custom-shadow-rg rounded"
/> inline
</div> />
</Popover.Panel> </div>
</Popover.Panel>
)}
</> </>
)} )}
</Popover> </Popover>

View File

@ -1,4 +1,4 @@
import { Fragment, ReactNode, useEffect, useState } from "react"; import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -7,6 +7,8 @@ import { Check, ChevronDown, Search, Triangle } from "lucide-react";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
// hooks // hooks
import { useApplication, useEstimate } from "hooks/store"; import { useApplication, useEstimate } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
// types // types
@ -24,6 +26,7 @@ type Props = {
placement?: Placement; placement?: Placement;
projectId: string; projectId: string;
value: number | null; value: number | null;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -102,9 +105,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
placement, placement,
projectId, projectId,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -160,15 +167,26 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const selectedEstimate = value !== null ? getEstimatePointValue(value) : null; const selectedEstimate = value !== null ? getEstimatePointValue(value) : null;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -176,6 +194,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -191,6 +210,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -235,53 +255,55 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -1,10 +1,12 @@
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react"; import { Check, Search } from "lucide-react";
// hooks // hooks
import { useApplication, useMember, useUser } from "hooks/store"; import { useApplication, useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns"; import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
// icons // icons
@ -33,9 +35,13 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
placement, placement,
projectId, projectId,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -93,12 +99,23 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
if (!projectMemberIds) fetchProjectMembers(workspaceSlug, projectId); if (!projectMemberIds) fetchProjectMembers(workspaceSlug, projectId);
}, [fetchProjectMembers, projectId, projectMemberIds, workspaceSlug]); }, [fetchProjectMembers, projectId, projectMemberIds, workspaceSlug]);
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
onKeyDown={handleKeyDown}
{...comboboxProps} {...comboboxProps}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
@ -107,6 +124,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -122,6 +140,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -172,53 +191,55 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -11,6 +11,7 @@ export type MemberDropdownProps = {
dropdownArrow?: boolean; dropdownArrow?: boolean;
placeholder?: string; placeholder?: string;
placement?: Placement; placement?: Placement;
tabIndex?: number;
} & ( } & (
| { | {
multiple: false; multiple: false;

View File

@ -1,4 +1,4 @@
import { Fragment, ReactNode, useEffect, useState } from "react"; import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
// hooks // hooks
import { useApplication, useModule } from "hooks/store"; import { useApplication, useModule } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons // icons
import { DiceIcon } from "@plane/ui"; import { DiceIcon } from "@plane/ui";
// helpers // helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement; placement?: Placement;
projectId: string; projectId: string;
value: string | null; value: string | null;
tabIndex?: number;
}; };
type DropdownOptions = type DropdownOptions =
@ -104,9 +107,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
placement, placement,
projectId, projectId,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -166,15 +173,26 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const selectedModule = value ? getModuleById(value) : null; const selectedModule = value ? getModuleById(value) : null;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -182,6 +200,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -197,6 +216,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -241,53 +261,55 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -1,8 +1,11 @@
import { Fragment, ReactNode, useState } from "react"; import { Fragment, ReactNode, useRef, useState } from "react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core"; import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons // icons
import { PriorityIcon } from "@plane/ui"; import { PriorityIcon } from "@plane/ui";
// helpers // helpers
@ -25,6 +28,7 @@ type Props = {
onChange: (val: TIssuePriorities) => void; onChange: (val: TIssuePriorities) => void;
placement?: Placement; placement?: Placement;
value: TIssuePriorities; value: TIssuePriorities;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -210,9 +214,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
onChange, onChange,
placement, placement,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -269,15 +277,26 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const filteredOptions = const filteredOptions =
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -285,6 +304,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -300,6 +320,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -350,49 +371,51 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ 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 matching results</p>
)}
</div>
</div> </div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </Combobox.Options>
{filteredOptions.length > 0 ? ( )}
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ 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 matching results</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox> </Combobox>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Fragment, ReactNode, useState } from "react"; import { Fragment, ReactNode, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
// hooks // hooks
import { useProject } from "hooks/store"; import { useProject } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
@ -24,6 +26,7 @@ type Props = {
onChange: (val: string) => void; onChange: (val: string) => void;
placement?: Placement; placement?: Placement;
value: string | null; value: string | null;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -99,9 +102,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
onChange, onChange,
placement, placement,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -146,15 +153,26 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const selectedProject = value ? getProjectById(value) : null; const selectedProject = value ? getProjectById(value) : null;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -162,6 +180,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -177,6 +196,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -221,53 +241,55 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -1,4 +1,4 @@
import { Fragment, ReactNode, useEffect, useState } from "react"; import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
// hooks // hooks
import { useApplication, useProjectState } from "hooks/store"; import { useApplication, useProjectState } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons // icons
import { StateGroupIcon } from "@plane/ui"; import { StateGroupIcon } from "@plane/ui";
// helpers // helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement; placement?: Placement;
projectId: string; projectId: string;
value: string; value: string;
tabIndex?: number;
}; };
type ButtonProps = { type ButtonProps = {
@ -96,9 +99,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
placement, placement,
projectId, projectId,
value, value,
tabIndex,
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -144,15 +151,26 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const selectedState = getStateById(value); const selectedState = getStateById(value);
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", { className={cn("h-full flex-shrink-0", {
className, className,
})} })}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
{button ? ( {button ? (
@ -160,6 +178,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
> >
{button} {button}
</button> </button>
@ -175,6 +194,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
}, },
buttonContainerClassName buttonContainerClassName
)} )}
onClick={openDropdown}
> >
{buttonVariant === "border-with-text" ? ( {buttonVariant === "border-with-text" ? (
<BorderButton <BorderButton
@ -219,53 +239,55 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" <div
ref={setPopperElement} className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
style={styles.popper} ref={setPopperElement}
{...attributes.popper} 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} /> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search" onChange={(e) => setQuery(e.target.value)}
displayValue={(assigned: any) => assigned?.name} placeholder="Search"
/> displayValue={(assigned: any) => assigned?.name}
</div> />
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> </div>
{filteredOptions ? ( <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
filteredOptions.length > 0 ? ( {filteredOptions ? (
filteredOptions.map((option) => ( filteredOptions.length > 0 ? (
<Combobox.Option filteredOptions.map((option) => (
key={option.value} <Combobox.Option
value={option.value} key={option.value}
className={({ active, selected }) => value={option.value}
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ className={({ active, selected }) =>
active ? "bg-custom-background-80" : "" `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` active ? "bg-custom-background-80" : ""
} } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
> }
{({ selected }) => ( >
<> {({ selected }) => (
<span className="flex-grow truncate">{option.content}</span> <>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <span className="flex-grow truncate">{option.content}</span>
</> {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
)} </>
</Combobox.Option> )}
)) </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">No matches found</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
) )}
) : ( </div>
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div> </div>
</div> </Combobox.Options>
</Combobox.Options> )}
</Combobox> </Combobox>
); );
}); });

View File

@ -11,4 +11,5 @@ export type Props = {
) => void; ) => void;
onIconColorChange?: (data: any) => void; onIconColorChange?: (data: any) => void;
disabled?: boolean; disabled?: boolean;
tabIndex?: number;
}; };

View File

@ -1,7 +1,10 @@
import React, { Fragment, useState } from "react"; import React, { Fragment, useRef, useState } from "react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react"; import { Popover } from "@headlessui/react";
import { Placement } from "@popperjs/core"; import { Placement } from "@popperjs/core";
// hooks
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// icons // icons
@ -12,20 +15,32 @@ type Props = {
title?: string; title?: string;
placement?: Placement; placement?: Placement;
disabled?: boolean; disabled?: boolean;
tabIndex?: number;
}; };
export const FiltersDropdown: React.FC<Props> = (props) => { export const FiltersDropdown: React.FC<Props> = (props) => {
const { children, title = "Dropdown", placement, disabled = false } = props; const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | 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, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto", placement: placement ?? "auto",
}); });
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Popover as="div"> <Popover as="div" ref={dropdownRef} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
{({ open }) => { {({ open }) => {
if (open) { if (open) {
} }
@ -40,22 +55,15 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
appendIcon={ appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} /> <ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
} }
onClick={openDropdown}
> >
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}> <div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span> <span>{title}</span>
</div> </div>
</Button> </Button>
</Popover.Button> </Popover.Button>
<Transition {isOpen && (
as={Fragment} <Popover.Panel static>
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 <div
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg" className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
ref={setPopperElement} ref={setPopperElement}
@ -65,7 +73,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
<div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div> <div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div>
</div> </div>
</Popover.Panel> </Popover.Panel>
</Transition> )}
</> </>
); );
}} }}

View File

@ -209,6 +209,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={19}
/> />
</div> </div>
)} )}
@ -238,6 +239,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange(); handleFormChange();
setSelectedParentIssue(null); setSelectedParentIssue(null);
}} }}
tabIndex={20}
/> />
</div> </div>
</div> </div>
@ -268,6 +270,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Issue Title" placeholder="Issue Title"
className="resize-none text-xl w-full" className="resize-none text-xl w-full"
tabIndex={1}
/> />
)} )}
/> />
@ -281,6 +284,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}`} }`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
tabIndex={3}
> >
{iAmFeelingLucky ? ( {iAmFeelingLucky ? (
"Generating response" "Generating response"
@ -309,6 +313,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
type="button" type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90" className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptAssistantModal((prevData) => !prevData)} onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={4}
> >
<Sparkle className="h-4 w-4" /> <Sparkle className="h-4 w-4" />
AI AI
@ -340,6 +345,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
mentionHighlights={mentionHighlights} mentionHighlights={mentionHighlights}
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
// tabIndex={2}
/> />
)} )}
/> />
@ -358,6 +364,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={6}
/> />
</div> </div>
)} )}
@ -374,6 +381,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={7}
/> />
</div> </div>
)} )}
@ -394,6 +402,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
placeholder="Assignees" placeholder="Assignees"
multiple multiple
tabIndex={8}
/> />
</div> </div>
)} )}
@ -411,6 +420,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange(); handleFormChange();
}} }}
projectId={projectId} projectId={projectId}
tabIndex={9}
/> />
</div> </div>
)} )}
@ -429,6 +439,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
tabIndex={10}
/> />
</div> </div>
)} )}
@ -447,6 +458,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Due date" placeholder="Due date"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
tabIndex={11}
/> />
</div> </div>
)} )}
@ -465,6 +477,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
value={value} value={value}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={12}
/> />
</div> </div>
)} )}
@ -484,6 +497,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={13}
/> />
</div> </div>
)} )}
@ -503,6 +517,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={14}
/> />
</div> </div>
)} )}
@ -532,6 +547,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
</button> </button>
} }
placement="bottom-start" placement="bottom-start"
tabIndex={15}
> >
{watch("parent_id") ? ( {watch("parent_id") ? (
<> <>
@ -578,6 +594,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<div <div
className="flex cursor-default items-center gap-1.5" className="flex cursor-default items-center gap-1.5"
onClick={() => setCreateMore((prevData) => !prevData)} onClick={() => setCreateMore((prevData) => !prevData)}
onKeyDown={(e) => {
if (e.key === "Enter") setCreateMore((prevData) => !prevData);
}}
tabIndex={16}
> >
<div className="flex cursor-pointer items-center justify-center"> <div className="flex cursor-pointer items-center justify-center">
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" /> <ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
@ -585,10 +605,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<span className="text-xs">Create more</span> <span className="text-xs">Create more</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={17}>
Discard Discard
</Button> </Button>
<Button type="submit" variant="primary" size="sm" loading={isSubmitting}> <Button type="submit" variant="primary" size="sm" loading={isSubmitting} tabIndex={18}>
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"} {data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
</Button> </Button>
</div> </div>

View File

@ -1,11 +1,13 @@
import React, { Fragment, useState } from "react"; import React, { Fragment, useRef, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { Combobox, Transition } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useLabel } from "hooks/store"; import { useLabel } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui // ui
import { IssueLabelsList } from "components/ui"; import { IssueLabelsList } from "components/ui";
// icons // icons
@ -18,10 +20,11 @@ type Props = {
projectId: string; projectId: string;
label?: JSX.Element; label?: JSX.Element;
disabled?: boolean; disabled?: boolean;
tabIndex?: number;
}; };
export const IssueLabelSelect: React.FC<Props> = observer((props) => { export const IssueLabelSelect: React.FC<Props> = observer((props) => {
const { setIsOpen, value, onChange, projectId, label, disabled = false } = props; const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -33,6 +36,9 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper // popper
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start", placement: "bottom-start",
@ -46,86 +52,122 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId) : null workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId) : null
); );
const openDropdown = () => {
setIsDropdownOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsDropdownOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isDropdownOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value} value={value}
onChange={(val) => onChange(val)} onChange={(val) => onChange(val)}
className="relative flex-shrink-0 h-full" className="relative flex-shrink-0 h-full"
multiple multiple
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown}
> >
{({ open }: any) => ( <Combobox.Button as={Fragment}>
<> <button
<Combobox.Button as={Fragment}> type="button"
<button ref={setReferenceElement}
type="button" className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200"
ref={setReferenceElement} onClick={openDropdown}
className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200" >
> {label ? (
{label ? ( label
label ) : value && value.length > 0 ? (
) : value && value.length > 0 ? ( <span className="flex items-center justify-center gap-2 text-xs">
<span className="flex items-center justify-center gap-2 text-xs"> <IssueLabelsList
<IssueLabelsList labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []} length={3}
length={3} showLength
showLength />
/> </span>
</span> ) : (
) : ( <div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80"> <Tag className="h-3 w-3 flex-shrink-0" />
<Tag className="h-3 w-3 flex-shrink-0" /> <span>Labels</span>
<span>Labels</span> </div>
</div> )}
)} </button>
</button> </Combobox.Button>
</Combobox.Button>
<Transition {isDropdownOpen && (
show={open} <Combobox.Options className="fixed z-10" static>
as={React.Fragment} <div
enter="transition ease-out duration-200" className="my-1 w-48 rounded border-[0.5px] border-custom-border-300
enterFrom="opacity-0 translate-y-1" bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
enterTo="opacity-100 translate-y-0" ref={setPopperElement}
leave="transition ease-in duration-150" style={styles.popper}
leaveFrom="opacity-100 translate-y-0" {...attributes.popper}
leaveTo="opacity-0 translate-y-1"
> >
<Combobox.Options className="fixed z-10"> <div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<div <Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 <Combobox.Input
bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
ref={setPopperElement} onChange={(event) => setQuery(event.target.value)}
style={styles.popper} placeholder="Search"
{...attributes.popper} displayValue={(assigned: any) => assigned?.name}
> />
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2"> </div>
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} /> <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
<Combobox.Input {projectLabels && filteredOptions ? (
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" filteredOptions.length > 0 ? (
onChange={(event) => setQuery(event.target.value)} filteredOptions.map((label) => {
placeholder="Search" const children = projectLabels?.filter((l) => l.parent === label.id);
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{projectLabels && filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((label) => {
const children = projectLabels?.filter((l) => l.parent === label.id);
if (children.length === 0) { if (children.length === 0) {
if (!label.parent) if (!label.parent)
return ( return (
<Combobox.Option
key={label.id}
className={({ active }) =>
`${
active ? "bg-custom-background-80" : ""
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
}
value={label.id}
>
{({ selected }) => (
<div className="flex w-full justify-between gap-2 rounded">
<div className="flex items-center justify-start gap-2">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<span>{label.name}</span>
</div>
<div className="flex items-center justify-center rounded p-1">
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
</div>
</div>
)}
</Combobox.Option>
);
} else
return (
<div className="border-y border-custom-border-200">
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100">
<Component className="h-3 w-3" /> {label.name}
</div>
<div>
{children.map((child) => (
<Combobox.Option <Combobox.Option
key={label.id} key={child.id}
className={({ active }) => className={({ active }) =>
`${ `${
active ? "bg-custom-background-80" : "" active ? "bg-custom-background-80" : ""
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
} }
value={label.id} value={child.id}
> >
{({ selected }) => ( {({ selected }) => (
<div className="flex w-full justify-between gap-2 rounded"> <div className="flex w-full justify-between gap-2 rounded">
@ -133,10 +175,10 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
<span <span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full" className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: label.color, backgroundColor: child?.color,
}} }}
/> />
<span>{label.name}</span> <span>{child.name}</span>
</div> </div>
<div className="flex items-center justify-center rounded p-1"> <div className="flex items-center justify-center rounded p-1">
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} /> <Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
@ -144,65 +186,28 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
</Combobox.Option> </Combobox.Option>
); ))}
} else </div>
return ( </div>
<div className="border-y border-custom-border-200"> );
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100"> })
<Component className="h-3 w-3" /> {label.name} ) : (
</div> <p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
<div> )
{children.map((child) => ( ) : (
<Combobox.Option <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
key={child.id} )}
className={({ active }) => <button
`${ type="button"
active ? "bg-custom-background-80" : "" className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80"
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` onClick={() => setIsOpen(true)}
} >
value={child.id} <Plus className="h-3 w-3" aria-hidden="true" />
> <span className="whitespace-nowrap">Create new label</span>
{({ selected }) => ( </button>
<div className="flex w-full justify-between gap-2 rounded"> </div>
<div className="flex items-center justify-start gap-2"> </div>
<span </Combobox.Options>
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: child?.color,
}}
/>
<span>{child.name}</span>
</div>
<div className="flex items-center justify-center rounded p-1">
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
</div>
</div>
)}
</Combobox.Option>
))}
</div>
</div>
);
})
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
<button
type="button"
className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80"
onClick={() => setIsOpen(true)}
>
<Plus className="h-3 w-3" aria-hidden="true" />
<span className="whitespace-nowrap">Create new label</span>
</button>
</div>
</div>
</Combobox.Options>
</Transition>
</>
)} )}
</Combobox> </Combobox>
); );

View File

@ -93,6 +93,7 @@ export const ModuleForm: React.FC<Props> = ({
setActiveProject(val); setActiveProject(val);
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={10}
/> />
</div> </div>
)} )}
@ -124,6 +125,7 @@ export const ModuleForm: React.FC<Props> = ({
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Module Title" placeholder="Module Title"
className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400" className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400"
tabIndex={1}
/> />
)} )}
/> />
@ -141,6 +143,7 @@ export const ModuleForm: React.FC<Props> = ({
placeholder="Description..." placeholder="Description..."
className="h-24 w-full resize-none text-sm" className="h-24 w-full resize-none text-sm"
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}
tabIndex={2}
/> />
)} )}
/> />
@ -157,6 +160,7 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
tabIndex={3}
/> />
</div> </div>
)} )}
@ -172,11 +176,12 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Target date" placeholder="Target date"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
tabIndex={4}
/> />
</div> </div>
)} )}
/> />
<ModuleStatusSelect control={control} error={errors.status} /> <ModuleStatusSelect control={control} error={errors.status} tabIndex={5} />
<Controller <Controller
control={control} control={control}
name="lead" name="lead"
@ -189,6 +194,7 @@ export const ModuleForm: React.FC<Props> = ({
multiple={false} multiple={false}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Lead" placeholder="Lead"
tabIndex={6}
/> />
</div> </div>
)} )}
@ -206,6 +212,7 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"} buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""} buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
placeholder="Members" placeholder="Members"
tabIndex={7}
/> />
</div> </div>
)} )}
@ -214,10 +221,10 @@ export const ModuleForm: React.FC<Props> = ({
</div> </div>
</div> </div>
<div className="mt-5 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 pt-5"> <div className="mt-5 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 pt-5">
<Button variant="neutral-primary" size="sm" onClick={handleClose}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={8}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={9}>
{status ? (isSubmitting ? "Updating" : "Update module") : isSubmitting ? "Creating" : "Create module"} {status ? (isSubmitting ? "Updating" : "Update module") : isSubmitting ? "Creating" : "Create module"}
</Button> </Button>
</div> </div>

View File

@ -12,9 +12,10 @@ import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
control: Control<IModule, any>; control: Control<IModule, any>;
error?: FieldError; error?: FieldError;
tabIndex?: number;
}; };
export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => ( export const ModuleStatusSelect: React.FC<Props> = ({ control, error, tabIndex }) => (
<Controller <Controller
control={control} control={control}
rules={{ required: true }} rules={{ required: true }}
@ -35,6 +36,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
</div> </div>
} }
onChange={onChange} onChange={onChange}
tabIndex={tabIndex}
noChevron noChevron
> >
{MODULE_STATUS.map((status) => ( {MODULE_STATUS.map((status) => (

View File

@ -59,6 +59,7 @@ export const PageForm: React.FC<Props> = (props) => {
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Title" placeholder="Title"
className="w-full resize-none text-lg" className="w-full resize-none text-lg"
tabIndex={1}
/> />
)} )}
/> />
@ -72,7 +73,7 @@ export const PageForm: React.FC<Props> = (props) => {
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1"> <div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
{PAGE_ACCESS_SPECIFIERS.map((access) => ( {PAGE_ACCESS_SPECIFIERS.map((access, index) => (
<Tooltip key={access.key} tooltipContent={access.label}> <Tooltip key={access.key} tooltipContent={access.label}>
<button <button
type="button" type="button"
@ -80,6 +81,7 @@ export const PageForm: React.FC<Props> = (props) => {
className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${ className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${
value === access.key ? "bg-custom-background-90" : "" value === access.key ? "bg-custom-background-90" : ""
}`} }`}
tabIndex={2 + index}
> >
<access.icon <access.icon
className={`h-3.5 w-3.5 ${ className={`h-3.5 w-3.5 ${
@ -98,10 +100,10 @@ export const PageForm: React.FC<Props> = (props) => {
)} )}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"} {data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
</Button> </Button>
</div> </div>

View File

@ -226,7 +226,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
)} )}
<div className="absolute right-2 top-2 p-2"> <div className="absolute right-2 top-2 p-2">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose}> <button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={8}>
<X className="h-5 w-5 text-white" /> <X className="h-5 w-5 text-white" />
</button> </button>
</div> </div>
@ -238,6 +238,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
}} }}
control={control} control={control}
value={watch("cover_image")} value={watch("cover_image")}
tabIndex={9}
/> />
</div> </div>
<div className="absolute -bottom-[22px] left-3"> <div className="absolute -bottom-[22px] left-3">
@ -253,6 +254,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
tabIndex={10}
/> />
)} )}
/> />
@ -278,11 +280,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
name="name" name="name"
type="text" type="text"
value={value} value={value}
tabIndex={1}
onChange={handleNameChange(onChange)} onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Project Title" placeholder="Project Title"
className="w-full focus:border-blue-400" className="w-full focus:border-blue-400"
tabIndex={1}
/> />
)} )}
/> />
@ -313,11 +315,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
name="identifier" name="identifier"
type="text" type="text"
value={value} value={value}
tabIndex={2}
onChange={handleIdentifierChange(onChange)} onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)} hasError={Boolean(errors.identifier)}
placeholder="Identifier" placeholder="Identifier"
className="w-full text-xs focus:border-blue-400 uppercase" className="w-full text-xs focus:border-blue-400 uppercase"
tabIndex={2}
/> />
)} )}
/> />
@ -332,11 +334,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
id="description" id="description"
name="description" name="description"
value={value} value={value}
tabIndex={3}
placeholder="Description..." placeholder="Description..."
onChange={onChange} onChange={onChange}
className="!h-24 text-sm focus:border-blue-400" className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}
tabIndex={3}
/> />
)} )}
/> />
@ -366,6 +368,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
} }
placement="bottom-start" placement="bottom-start"
noChevron noChevron
tabIndex={4}
> >
{NETWORK_CHOICES.map((network) => ( {NETWORK_CHOICES.map((network) => (
<CustomSelect.Option <CustomSelect.Option
@ -392,6 +395,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
placeholder="Lead" placeholder="Lead"
multiple={false} multiple={false}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={5}
/> />
</div> </div>
)} )}

View File

@ -130,6 +130,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Title" placeholder="Title"
className="w-full resize-none text-xl focus:border-blue-400" className="w-full resize-none text-xl focus:border-blue-400"
tabIndex={1}
/> />
)} )}
/> />
@ -147,6 +148,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}
value={value} value={value}
onChange={onChange} onChange={onChange}
tabIndex={2}
/> />
)} )}
/> />
@ -156,7 +158,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
control={control} control={control}
name="query_data" name="query_data"
render={({ field: { onChange, value: filters } }) => ( render={({ field: { onChange, value: filters } }) => (
<FiltersDropdown title="Filters"> <FiltersDropdown title="Filters" tabIndex={3}>
<FilterSelection <FilterSelection
filters={filters ?? {}} filters={filters ?? {}}
handleFiltersUpdate={(key, value) => { handleFiltersUpdate={(key, value) => {
@ -199,10 +201,10 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit"> <Button variant="primary" size="sm" type="submit" tabIndex={5}>
{data {data
? isSubmitting ? isSubmitting
? "Updating View..." ? "Updating View..."

View File

@ -0,0 +1,24 @@
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) {
onOpen();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
onClose();
}
},
[isOpen, onOpen, onClose]
);
return handleKeyDown;
};

View File

@ -1,95 +1,214 @@
import { observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import omit from "lodash/omit"; import omit from "lodash/omit";
import isToday from "date-fns/isToday"; import isToday from "date-fns/isToday";
import isThisWeek from "date-fns/isThisWeek"; import isThisWeek from "date-fns/isThisWeek";
import isYesterday from "date-fns/isYesterday"; import isYesterday from "date-fns/isYesterday";
// services
import { IPage } from "@plane/types";
import { PageService } from "services/page.service"; import { PageService } from "services/page.service";
import { is } from "date-fns/locale"; // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IPage, IRecentPages } from "@plane/types";
// store
import { RootStore } from "./root.store";
export interface IPageStore { export interface IPageStore {
access: number; pages: Record<string, IPage>;
archived_at: string | null; archivedPages: Record<string, IPage>;
color: string; // project computed
created_at: Date; projectPageIds: string[] | null;
created_by: string; favoriteProjectPageIds: string[] | null;
description: string; privateProjectPageIds: string[] | null;
description_html: string; publicProjectPageIds: string[] | null;
description_stripped: string | null; archivedProjectPageIds: string[] | null;
id: string; recentProjectPages: IRecentPages | null;
is_favorite: boolean; // fetch page information actions
is_locked: boolean; getUnArchivedPageById: (pageId: string) => IPage | null;
labels: string[]; getArchivedPageById: (pageId: string) => IPage | null;
name: string; // fetch actions
owned_by: string; fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
project: string; fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
updated_at: Date; // favorites actions
updated_by: string; addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
workspace: string; removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// crud
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// access control actions
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// archive actions
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
} }
export class PageStore { export class PageStore implements IPageStore {
access: number; pages: Record<string, IPage> = {};
archived_at: string | null; archivedPages: Record<string, IPage> = {};
color: string; // services
created_at: Date;
created_by: string;
description: string;
description_html: string;
description_stripped: string | null;
id: string;
is_favorite: boolean;
is_locked: boolean;
labels: string[];
name: string;
owned_by: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
pageService; pageService;
// stores
rootStore;
constructor(page: IPage) { constructor(rootStore: RootStore) {
observable(this, { makeObservable(this, {
name: observable.ref, pages: observable,
description_html: observable.ref, archivedPages: observable,
is_favorite: observable.ref, // computed
is_locked: observable.ref, projectPageIds: computed,
favoriteProjectPageIds: computed,
publicProjectPageIds: computed,
privateProjectPageIds: computed,
archivedProjectPageIds: computed,
recentProjectPages: computed,
// computed actions
getUnArchivedPageById: action,
getArchivedPageById: action,
// fetch actions
fetchProjectPages: action,
fetchArchivedProjectPages: action,
// favorites actions
addToFavorites: action,
removeFromFavorites: action,
// crud
createPage: action,
updatePage: action,
deletePage: action,
// access control actions
makePublic: action,
makePrivate: action,
// archive actions
archivePage: action,
restorePage: action,
}); });
this.created_by = page?.created_by || ""; // stores
this.created_at = page?.created_at || new Date(); this.rootStore = rootStore;
this.color = page?.color || ""; // services
this.archived_at = page?.archived_at || null;
this.name = page?.name || "";
this.description = page?.description || "";
this.description_stripped = page?.description_stripped || "";
this.description_html = page?.description_html || "";
this.access = page?.access || 0;
this.workspace = page?.workspace || "";
this.updated_by = page?.updated_by || "";
this.updated_at = page?.updated_at || new Date();
this.project = page?.project || "";
this.owned_by = page?.owned_by || "";
this.labels = page?.labels || [];
this.is_locked = page?.is_locked || false;
this.id = page?.id || "";
this.is_favorite = page?.is_favorite || false;
this.pageService = new PageService(); this.pageService = new PageService();
} }
updateName = async (name: string) => { /**
this.name = name; * retrieves all pages for a projectId that is available in the url.
await this.pageService.patchPage(this.workspace, this.project, this.id, { name }); */
}; get projectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null;
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId);
return projectPageIds ?? null;
}
updateDescription = async (description: string) => { /**
this.description = description; * retrieves all favorite pages for a projectId that is available in the url.
await this.pageService.patchPage(this.workspace, this.project, this.id, { description }); */
}; get favoriteProjectPageIds() {
if (!this.projectPageIds) return null;
const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite);
return favoritePagesIds ?? null;
}
/**
* retrieves all private pages for a projectId that is available in the url.
*/
get privateProjectPageIds() {
if (!this.projectPageIds) return null;
const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1);
return privatePagesIds ?? null;
}
/**
* retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url.
*/
get publicProjectPageIds() {
if (!this.projectPageIds) return null;
const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0);
return publicPagesIds ?? null;
}
/**
* retrieves all recent pages for a projectId that is available in the url.
* In format where today, yesterday, this_week, older are keys.
*/
get recentProjectPages() {
if (!this.projectPageIds) return null;
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.created_at))) || [];
data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.created_at))) || [];
data.this_week =
this.projectPageIds.filter((p) => {
const pageCreatedAt = this.pages?.[p]?.created_at;
return (
isThisWeek(new Date(pageCreatedAt)) &&
!isToday(new Date(pageCreatedAt)) &&
!isYesterday(new Date(pageCreatedAt))
);
}) || [];
data.older =
this.projectPageIds.filter((p) => {
const pageCreatedAt = this.pages?.[p]?.created_at;
return !isThisWeek(new Date(pageCreatedAt)) && !isYesterday(new Date(pageCreatedAt));
}) || [];
return data;
}
/**
* retrieves all archived pages for a projectId that is available in the url.
*/
get archivedProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null;
const archivedProjectPageIds = Object.keys(this.archivedPages).filter(
(pageId) => this.archivedPages?.[pageId]?.project === projectId
);
return archivedProjectPageIds ?? null;
}
/**
* retrieves a page from pages by id.
* @param pageId
* @returns IPage | null
*/
getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null;
/**
* retrieves a page from archived pages by id.
* @param pageId
* @returns IPage | null
*/
getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null;
/**
* fetches all pages for a project.
* @param workspaceSlug
* @param projectId
* @returns Promise<IPage[]>
*/
fetchProjectPages = async (workspaceSlug: string, projectId: string) =>
await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
response.forEach((page) => {
set(this.pages, [page.id], page);
});
});
return response;
});
/**
* fetches all archived pages for a project.
* @param workspaceSlug
* @param projectId
* @returns Promise<IPage[]>
*/
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
response.forEach((page) => {
set(this.archivedPages, [page.id], page);
});
});
return response;
});
/** /**
* Add Page to users favorites list * Add Page to users favorites list
@ -100,11 +219,13 @@ export class PageStore {
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try { try {
runInAction(() => { runInAction(() => {
this.is_favorite = true; set(this.pages, [pageId, "is_favorite"], true);
}); });
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId); await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
} catch (error) { } catch (error) {
this.is_favorite = false; runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
throw error; throw error;
} }
}; };
@ -117,13 +238,62 @@ export class PageStore {
*/ */
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try { try {
this.is_favorite = false; runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId); await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
} catch (error) { } catch (error) {
this.is_favorite = true; runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true);
});
throw error; throw error;
} }
}; };
/**
* Creates a new page using the api and updated the local state in store
* @param workspaceSlug
* @param projectId
* @param data
*/
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) =>
await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.pages, [response.id], response);
});
return response;
});
/**
* updates the page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @param data
* @returns
*/
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) =>
await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => {
const originalPage = this.getUnArchivedPageById(pageId);
runInAction(() => {
set(this.pages, [pageId], { ...originalPage, ...data });
});
return response;
});
/**
* delete a page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns
*/
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => {
runInAction(() => {
omit(this.archivedPages, [pageId]);
});
return response;
});
/** /**
* make a page public * make a page public
@ -135,12 +305,12 @@ export class PageStore {
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => { makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
try { try {
runInAction(() => { runInAction(() => {
this.access = 0; set(this.pages, [pageId, "access"], 0);
}); });
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 }); await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {
this.access = 1; set(this.pages, [pageId, "access"], 1);
}); });
throw error; throw error;
} }
@ -156,14 +326,43 @@ export class PageStore {
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => { makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
try { try {
runInAction(() => { runInAction(() => {
this.access = 1; set(this.pages, [pageId, "access"], 1);
}); });
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 }); await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {
this.access = 0; set(this.pages, [pageId, "access"], 0);
}); });
throw error; throw error;
} }
}; };
/**
* Mark a page archived
* @param workspaceSlug
* @param projectId
* @param pageId
*/
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
runInAction(() => {
set(this.archivedPages, [pageId], this.pages[pageId]);
set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date()));
omit(this.pages, [pageId]);
});
});
/**
* Restore a page from archived pages to pages
* @param workspaceSlug
* @param projectId
* @param pageId
*/
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
runInAction(() => {
set(this.pages, [pageId], this.archivedPages[pageId]);
omit(this.archivedPages, [pageId]);
});
});
} }

View File

@ -46,7 +46,7 @@ export class ProjectPageStore implements IProjectPageStore {
fetchProjectPages = async (workspaceSlug: string, projectId: string) => { fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
const response = await this.pageService.getProjectPages(workspaceSlug, projectId); const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
runInAction(() => { runInAction(() => {
this.projectPages[projectId] = response?.map((page) => new PageStore(page)); this.projectPages[projectId] = response?.map((page) => new PageStore(page as any));
}); });
}; };
@ -59,7 +59,7 @@ export class ProjectPageStore implements IProjectPageStore {
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => { runInAction(() => {
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page)); this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
}); });
return response; return response;
}); });
@ -73,7 +73,7 @@ export class ProjectPageStore implements IProjectPageStore {
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => { createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
const response = await this.pageService.createPage(workspaceSlug, projectId, data); const response = await this.pageService.createPage(workspaceSlug, projectId, data);
runInAction(() => { runInAction(() => {
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response)]; this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)];
}); });
return response; return response;
}; };
@ -91,7 +91,7 @@ export class ProjectPageStore implements IProjectPageStore {
this.projectPages = set( this.projectPages = set(
this.projectPages, this.projectPages,
[projectId], [projectId],
this.projectPages[projectId].filter((page) => page.id !== pageId) this.projectPages[projectId].filter((page: any) => page.id !== pageId)
); );
}); });
return response; return response;
@ -109,7 +109,7 @@ export class ProjectPageStore implements IProjectPageStore {
set( set(
this.projectPages, this.projectPages,
[projectId], [projectId],
this.projectPages[projectId].filter((page) => page.id !== pageId) this.projectPages[projectId].filter((page: any) => page.id !== pageId)
); );
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]); this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
}); });
@ -128,7 +128,7 @@ export class ProjectPageStore implements IProjectPageStore {
set( set(
this.projectArchivedPages, this.projectArchivedPages,
[projectId], [projectId],
this.projectArchivedPages[projectId].filter((page) => page.id !== pageId) this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
); );
set(this.projectPages, [projectId], [...this.projectPages[projectId]]); set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
}); });

View File

@ -32,7 +32,7 @@ export class RootStore {
module: IModuleStore; module: IModuleStore;
projectView: IProjectViewStore; projectView: IProjectViewStore;
globalView: IGlobalViewStore; globalView: IGlobalViewStore;
// page: IPageStore; page: IPageStore;
issue: IIssueRootStore; issue: IIssueRootStore;
state: IStateStore; state: IStateStore;
estimate: IEstimateStore; estimate: IEstimateStore;
@ -57,5 +57,6 @@ export class RootStore {
this.estimate = new EstimateStore(this); this.estimate = new EstimateStore(this);
this.mention = new MentionStore(this); this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(); this.projectPages = new ProjectPageStore();
this.page = new PageStore(this);
} }
} }