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

View File

@ -1,7 +1,10 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
// 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
import { Combobox } from "@headlessui/react";
// icons
@ -29,11 +32,15 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
optionsClassName = "",
value,
width = "auto",
tabIndex,
} = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
@ -50,8 +57,23 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
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 (
<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 }) => {
if (open && onOpen) onOpen();
@ -67,6 +89,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
onClick={openDropdown}
>
{customButton}
</button>
@ -83,86 +106,89 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={openDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
)}
<Combobox.Options as={React.Fragment}>
<div
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>
{isOpen && (
<Combobox.Options as={React.Fragment} static>
<div
className={`mt-2 space-y-1 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} overflow-y-scroll`}
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}
>
{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"
}`}
>
<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
className={`mt-2 space-y-1 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: 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"}`} />
</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-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
{footerOption}
</div>
{footerOption}
</div>
</Combobox.Options>
</Combobox.Options>
)}
</>
);
}}

View File

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

View File

@ -15,6 +15,7 @@ export interface IDropdownProps {
optionsClassName?: string;
width?: "auto" | string;
placement?: Placement;
tabIndex?: number;
}
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 useSWR from "swr";
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";
// hooks
import { useApplication, useWorkspace } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// services
import { FileService } from "services/file.service";
// hooks
@ -38,13 +39,14 @@ type Props = {
control: Control<any>;
onChange: (data: string) => void;
disabled?: boolean;
tabIndex?: number;
};
// services
const fileService = new FileService();
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
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
@ -128,27 +130,27 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
onChange(unsplashImages[0].urls.regular);
}, [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 (
<Popover className="relative z-[2]" ref={ref}>
<Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
<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"
onClick={() => setIsOpen((prev) => !prev)}
onClick={openDropdown}
disabled={disabled}
>
{label}
</Popover.Button>
<Transition
show={isOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
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">
{isOpen && (
<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"
static
>
<div
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]"
@ -349,7 +351,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
</Tab.Group>
</div>
</Popover.Panel>
</Transition>
)}
</Popover>
);
});

View File

@ -59,6 +59,7 @@ export const CycleForm: React.FC<Props> = (props) => {
setActiveProject(val);
}}
buttonVariant="background-with-text"
tabIndex={7}
/>
)}
/>
@ -89,6 +90,7 @@ export const CycleForm: React.FC<Props> = (props) => {
inputSize="md"
onChange={onChange}
hasError={Boolean(errors?.name)}
tabIndex={1}
/>
)}
/>
@ -106,6 +108,7 @@ export const CycleForm: React.FC<Props> = (props) => {
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
tabIndex={2}
/>
)}
/>
@ -124,6 +127,7 @@ export const CycleForm: React.FC<Props> = (props) => {
buttonVariant="border-with-text"
placeholder="Start date"
maxDate={maxDate ?? undefined}
tabIndex={3}
/>
</div>
)}
@ -140,6 +144,7 @@ export const CycleForm: React.FC<Props> = (props) => {
buttonVariant="border-with-text"
placeholder="End date"
minDate={minDate}
tabIndex={4}
/>
</div>
)}
@ -149,10 +154,10 @@ export const CycleForm: React.FC<Props> = (props) => {
</div>
</div>
<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
</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"}
</Button>
</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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useCycle } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons
import { ContrastIcon } from "@plane/ui";
// helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement;
projectId: string;
value: string | null;
tabIndex?: number;
};
type ButtonProps = {
@ -104,9 +107,13 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -182,6 +200,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -197,6 +216,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -241,53 +261,55 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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 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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -1,9 +1,12 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { Popover } from "@headlessui/react";
import DatePicker from "react-datepicker";
import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react";
// 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
import { renderFormattedDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
@ -25,6 +28,7 @@ type Props = {
placement?: Placement;
value: Date | string | null;
closeOnSelect?: boolean;
tabIndex?: number;
};
type ButtonProps = {
@ -124,7 +128,11 @@ export const DateDropdown: React.FC<Props> = (props) => {
placement,
value,
closeOnSelect = true,
tabIndex,
} = props;
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Popover className="h-full flex-shrink-0">
<Popover ref={dropdownRef} tabIndex={tabIndex} className="h-full flex-shrink-0" onKeyDown={handleKeyDown}>
{({ close }) => (
<>
<Popover.Button as={React.Fragment}>
@ -159,6 +175,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -220,22 +237,24 @@ export const DateDropdown: React.FC<Props> = (props) => {
) : null}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<DatePicker
selected={value ? new Date(value) : null}
onChange={(val) => {
onChange(val);
if (closeOnSelect) close();
}}
dateFormat="dd-MM-yyyy"
minDate={minDate}
maxDate={maxDate}
calendarClassName="shadow-custom-shadow-rg rounded"
inline
/>
</div>
</Popover.Panel>
{isOpen && (
<Popover.Panel className="fixed z-10" static>
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<DatePicker
selected={value ? new Date(value) : null}
onChange={(val) => {
onChange(val);
if (closeOnSelect) close();
}}
dateFormat="dd-MM-yyyy"
minDate={minDate}
maxDate={maxDate}
calendarClassName="shadow-custom-shadow-rg rounded"
inline
/>
</div>
</Popover.Panel>
)}
</>
)}
</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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@ -7,6 +7,8 @@ import { Check, ChevronDown, Search, Triangle } from "lucide-react";
import sortBy from "lodash/sortBy";
// hooks
import { useApplication, useEstimate } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// helpers
import { cn } from "helpers/common.helper";
// types
@ -24,6 +26,7 @@ type Props = {
placement?: Placement;
projectId: string;
value: number | null;
tabIndex?: number;
};
type ButtonProps = {
@ -102,9 +105,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -176,6 +194,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -191,6 +210,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -235,53 +255,55 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
)
) : (
<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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
// hooks
import { useApplication, useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
// icons
@ -33,9 +35,13 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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);
}, [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 (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
<Combobox.Button as={Fragment}>
@ -107,6 +124,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -122,6 +140,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -172,53 +191,55 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
)
) : (
<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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -11,6 +11,7 @@ export type MemberDropdownProps = {
dropdownArrow?: boolean;
placeholder?: string;
placement?: Placement;
tabIndex?: number;
} & (
| {
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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useModule } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons
import { DiceIcon } from "@plane/ui";
// helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement;
projectId: string;
value: string | null;
tabIndex?: number;
};
type DropdownOptions =
@ -104,9 +107,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -182,6 +200,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -197,6 +216,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -241,53 +261,55 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
)
) : (
<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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</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 { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
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
import { PriorityIcon } from "@plane/ui";
// helpers
@ -25,6 +28,7 @@ type Props = {
onChange: (val: TIssuePriorities) => void;
placement?: Placement;
value: TIssuePriorities;
tabIndex?: number;
};
type ButtonProps = {
@ -210,9 +214,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
onChange,
placement,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -269,15 +277,26 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const filteredOptions =
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 (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -285,6 +304,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -300,6 +320,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -350,49 +371,51 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="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 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>
</Combobox.Options>
</Combobox.Options>
)}
</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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useProject } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// helpers
import { cn } from "helpers/common.helper";
import { renderEmoji } from "helpers/emoji.helper";
@ -24,6 +26,7 @@ type Props = {
onChange: (val: string) => void;
placement?: Placement;
value: string | null;
tabIndex?: number;
};
type ButtonProps = {
@ -99,9 +102,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
onChange,
placement,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -162,6 +180,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -177,6 +196,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -221,53 +241,55 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
)
) : (
<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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</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 { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useProjectState } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons
import { StateGroupIcon } from "@plane/ui";
// helpers
@ -26,6 +28,7 @@ type Props = {
placement?: Placement;
projectId: string;
value: string;
tabIndex?: number;
};
type ButtonProps = {
@ -96,9 +99,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
placement,
projectId,
value,
tabIndex,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | 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 openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full flex-shrink-0", {
className,
})}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
@ -160,6 +178,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
>
{button}
</button>
@ -175,6 +194,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
@ -219,53 +239,55 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
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 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>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -11,4 +11,5 @@ export type Props = {
) => void;
onIconColorChange?: (data: any) => void;
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 { Popover, Transition } from "@headlessui/react";
import { Popover } from "@headlessui/react";
import { Placement } from "@popperjs/core";
// hooks
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { Button } from "@plane/ui";
// icons
@ -12,20 +15,32 @@ type Props = {
title?: string;
placement?: Placement;
disabled?: boolean;
tabIndex?: number;
};
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 [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
return (
<Popover as="div">
<Popover as="div" ref={dropdownRef} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
{({ open }) => {
if (open) {
}
@ -40,22 +55,15 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
}
onClick={openDropdown}
>
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span>
</div>
</Button>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
{isOpen && (
<Popover.Panel static>
<div
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
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>
</Popover.Panel>
</Transition>
)}
</>
);
}}

View File

@ -209,6 +209,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={19}
/>
</div>
)}
@ -238,6 +239,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
setSelectedParentIssue(null);
}}
tabIndex={20}
/>
</div>
</div>
@ -268,6 +270,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
hasError={Boolean(errors.name)}
placeholder="Issue Title"
className="resize-none text-xl w-full"
tabIndex={1}
/>
)}
/>
@ -281,6 +284,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
tabIndex={3}
>
{iAmFeelingLucky ? (
"Generating response"
@ -309,6 +313,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
tabIndex={4}
>
<Sparkle className="h-4 w-4" />
AI
@ -340,6 +345,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}}
mentionHighlights={mentionHighlights}
mentionSuggestions={mentionSuggestions}
// tabIndex={2}
/>
)}
/>
@ -358,6 +364,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={6}
/>
</div>
)}
@ -374,6 +381,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={7}
/>
</div>
)}
@ -394,6 +402,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
placeholder="Assignees"
multiple
tabIndex={8}
/>
</div>
)}
@ -411,6 +420,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
}}
projectId={projectId}
tabIndex={9}
/>
</div>
)}
@ -429,6 +439,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonVariant="border-with-text"
placeholder="Start date"
maxDate={maxDate ?? undefined}
tabIndex={10}
/>
</div>
)}
@ -447,6 +458,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
buttonVariant="border-with-text"
placeholder="Due date"
minDate={minDate ?? undefined}
tabIndex={11}
/>
</div>
)}
@ -465,6 +477,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}}
value={value}
buttonVariant="border-with-text"
tabIndex={12}
/>
</div>
)}
@ -484,6 +497,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={13}
/>
</div>
)}
@ -503,6 +517,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}}
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={14}
/>
</div>
)}
@ -532,6 +547,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
</button>
}
placement="bottom-start"
tabIndex={15}
>
{watch("parent_id") ? (
<>
@ -578,6 +594,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<div
className="flex cursor-default items-center gap-1.5"
onClick={() => setCreateMore((prevData) => !prevData)}
onKeyDown={(e) => {
if (e.key === "Enter") setCreateMore((prevData) => !prevData);
}}
tabIndex={16}
>
<div className="flex cursor-pointer items-center justify-center">
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
@ -585,10 +605,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<span className="text-xs">Create more</span>
</div>
<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
</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"}
</Button>
</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 useSWR from "swr";
import { Combobox, Transition } from "@headlessui/react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { observer } from "mobx-react-lite";
// hooks
import { useLabel } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { IssueLabelsList } from "components/ui";
// icons
@ -18,10 +20,11 @@ type Props = {
projectId: string;
label?: JSX.Element;
disabled?: boolean;
tabIndex?: number;
};
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
const router = useRouter();
const { workspaceSlug } = router.query;
@ -33,6 +36,9 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
@ -46,86 +52,122 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
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 (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value}
onChange={(val) => onChange(val)}
className="relative flex-shrink-0 h-full"
multiple
disabled={disabled}
onKeyDown={handleKeyDown}
>
{({ open }: any) => (
<>
<Combobox.Button as={Fragment}>
<button
type="button"
ref={setReferenceElement}
className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200"
>
{label ? (
label
) : value && value.length > 0 ? (
<span className="flex items-center justify-center gap-2 text-xs">
<IssueLabelsList
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
length={3}
showLength
/>
</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">
<Tag className="h-3 w-3 flex-shrink-0" />
<span>Labels</span>
</div>
)}
</button>
</Combobox.Button>
<Combobox.Button as={Fragment}>
<button
type="button"
ref={setReferenceElement}
className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200"
onClick={openDropdown}
>
{label ? (
label
) : value && value.length > 0 ? (
<span className="flex items-center justify-center gap-2 text-xs">
<IssueLabelsList
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
length={3}
showLength
/>
</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">
<Tag className="h-3 w-3 flex-shrink-0" />
<span>Labels</span>
</div>
)}
</button>
</Combobox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
{isDropdownOpen && (
<Combobox.Options className="fixed z-10" static>
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Combobox.Options className="fixed z-10">
<div
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"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
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);
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
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 (!label.parent)
return (
if (children.length === 0) {
if (!label.parent)
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
key={label.id}
key={child.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}
value={child.id}
>
{({ selected }) => (
<div className="flex w-full justify-between gap-2 rounded">
@ -133,10 +175,10 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
backgroundColor: child?.color,
}}
/>
<span>{label.name}</span>
<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"}`} />
@ -144,65 +186,28 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
</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
key={child.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={child.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: 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>
</>
))}
</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>
)}
</Combobox>
);

View File

@ -93,6 +93,7 @@ export const ModuleForm: React.FC<Props> = ({
setActiveProject(val);
}}
buttonVariant="border-with-text"
tabIndex={10}
/>
</div>
)}
@ -124,6 +125,7 @@ export const ModuleForm: React.FC<Props> = ({
hasError={Boolean(errors.name)}
placeholder="Module Title"
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..."
className="h-24 w-full resize-none text-sm"
hasError={Boolean(errors?.description)}
tabIndex={2}
/>
)}
/>
@ -157,6 +160,7 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant="border-with-text"
placeholder="Start date"
maxDate={maxDate ?? undefined}
tabIndex={3}
/>
</div>
)}
@ -172,11 +176,12 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant="border-with-text"
placeholder="Target date"
minDate={minDate ?? undefined}
tabIndex={4}
/>
</div>
)}
/>
<ModuleStatusSelect control={control} error={errors.status} />
<ModuleStatusSelect control={control} error={errors.status} tabIndex={5} />
<Controller
control={control}
name="lead"
@ -189,6 +194,7 @@ export const ModuleForm: React.FC<Props> = ({
multiple={false}
buttonVariant="border-with-text"
placeholder="Lead"
tabIndex={6}
/>
</div>
)}
@ -206,6 +212,7 @@ export const ModuleForm: React.FC<Props> = ({
buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
placeholder="Members"
tabIndex={7}
/>
</div>
)}
@ -214,10 +221,10 @@ export const ModuleForm: React.FC<Props> = ({
</div>
</div>
<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
</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"}
</Button>
</div>

View File

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

View File

@ -59,6 +59,7 @@ export const PageForm: React.FC<Props> = (props) => {
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full resize-none text-lg"
tabIndex={1}
/>
)}
/>
@ -72,7 +73,7 @@ export const PageForm: React.FC<Props> = (props) => {
render={({ field: { value, onChange } }) => (
<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">
{PAGE_ACCESS_SPECIFIERS.map((access) => (
{PAGE_ACCESS_SPECIFIERS.map((access, index) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<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 ${
value === access.key ? "bg-custom-background-90" : ""
}`}
tabIndex={2 + index}
>
<access.icon
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">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel
</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"}
</Button>
</div>

View File

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

View File

@ -130,6 +130,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
hasError={Boolean(errors.name)}
placeholder="Title"
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)}
value={value}
onChange={onChange}
tabIndex={2}
/>
)}
/>
@ -156,7 +158,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
control={control}
name="query_data"
render={({ field: { onChange, value: filters } }) => (
<FiltersDropdown title="Filters">
<FiltersDropdown title="Filters" tabIndex={3}>
<FilterSelection
filters={filters ?? {}}
handleFiltersUpdate={(key, value) => {
@ -199,10 +201,10 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
</div>
</div>
<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
</Button>
<Button variant="primary" size="sm" type="submit">
<Button variant="primary" size="sm" type="submit" tabIndex={5}>
{data
? isSubmitting
? "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 omit from "lodash/omit";
import isToday from "date-fns/isToday";
import isThisWeek from "date-fns/isThisWeek";
import isYesterday from "date-fns/isYesterday";
import { IPage } from "@plane/types";
// services
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 {
access: number;
archived_at: string | null;
color: string;
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;
pages: Record<string, IPage>;
archivedPages: Record<string, IPage>;
// project computed
projectPageIds: string[] | null;
favoriteProjectPageIds: string[] | null;
privateProjectPageIds: string[] | null;
publicProjectPageIds: string[] | null;
archivedProjectPageIds: string[] | null;
recentProjectPages: IRecentPages | null;
// fetch page information actions
getUnArchivedPageById: (pageId: string) => IPage | null;
getArchivedPageById: (pageId: string) => IPage | null;
// fetch actions
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
// favorites actions
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
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 {
access: number;
archived_at: string | null;
color: string;
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;
export class PageStore implements IPageStore {
pages: Record<string, IPage> = {};
archivedPages: Record<string, IPage> = {};
// services
pageService;
// stores
rootStore;
constructor(page: IPage) {
observable(this, {
name: observable.ref,
description_html: observable.ref,
is_favorite: observable.ref,
is_locked: observable.ref,
constructor(rootStore: RootStore) {
makeObservable(this, {
pages: observable,
archivedPages: observable,
// computed
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 || "";
this.created_at = page?.created_at || new Date();
this.color = page?.color || "";
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;
// stores
this.rootStore = rootStore;
// services
this.pageService = new PageService();
}
updateName = async (name: string) => {
this.name = name;
await this.pageService.patchPage(this.workspace, this.project, this.id, { name });
};
/**
* retrieves all pages for a projectId that is available in the url.
*/
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;
await this.pageService.patchPage(this.workspace, this.project, this.id, { description });
};
/**
* retrieves all favorite pages for a projectId that is available in the url.
*/
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
@ -100,11 +219,13 @@ export class PageStore {
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.is_favorite = true;
set(this.pages, [pageId, "is_favorite"], true);
});
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
this.is_favorite = false;
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
throw error;
}
};
@ -117,13 +238,62 @@ export class PageStore {
*/
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
this.is_favorite = false;
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
this.is_favorite = true;
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true);
});
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
@ -135,12 +305,12 @@ export class PageStore {
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.access = 0;
set(this.pages, [pageId, "access"], 0);
});
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
} catch (error) {
runInAction(() => {
this.access = 1;
set(this.pages, [pageId, "access"], 1);
});
throw error;
}
@ -156,14 +326,43 @@ export class PageStore {
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.access = 1;
set(this.pages, [pageId, "access"], 1);
});
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
} catch (error) {
runInAction(() => {
this.access = 0;
set(this.pages, [pageId, "access"], 0);
});
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) => {
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
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) =>
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page));
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
});
return response;
});
@ -73,7 +73,7 @@ export class ProjectPageStore implements IProjectPageStore {
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
runInAction(() => {
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response)];
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)];
});
return response;
};
@ -91,7 +91,7 @@ export class ProjectPageStore implements IProjectPageStore {
this.projectPages = set(
this.projectPages,
[projectId],
this.projectPages[projectId].filter((page) => page.id !== pageId)
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
);
});
return response;
@ -109,7 +109,7 @@ export class ProjectPageStore implements IProjectPageStore {
set(
this.projectPages,
[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]);
});
@ -128,7 +128,7 @@ export class ProjectPageStore implements IProjectPageStore {
set(
this.projectArchivedPages,
[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]]);
});

View File

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