mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
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:
parent
08e5f2b156
commit
8b884ab681
@ -2,6 +2,9 @@ import * as React from "react";
|
|||||||
|
|
||||||
// react-poppper
|
// react-poppper
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Menu } from "@headlessui/react";
|
import { Menu } from "@headlessui/react";
|
||||||
// type
|
// type
|
||||||
@ -27,16 +30,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
verticalEllipsis = false,
|
verticalEllipsis = false,
|
||||||
width = "auto",
|
width = "auto",
|
||||||
menuButtonOnClick,
|
menuButtonOnClick,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: placement ?? "auto",
|
placement: placement ?? "auto",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className={`relative w-min text-left ${className}`}>
|
<Menu
|
||||||
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={`relative w-min text-left ${className}`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
{customButton ? (
|
{customButton ? (
|
||||||
@ -44,7 +66,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
<button
|
<button
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={menuButtonOnClick}
|
onClick={() => {
|
||||||
|
openDropdown();
|
||||||
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
|
}}
|
||||||
className={customButtonClassName}
|
className={customButtonClassName}
|
||||||
>
|
>
|
||||||
{customButton}
|
{customButton}
|
||||||
@ -57,7 +82,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
<button
|
<button
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={menuButtonOnClick}
|
onClick={() => {
|
||||||
|
openDropdown();
|
||||||
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
||||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
@ -78,6 +106,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
? "cursor-not-allowed text-custom-text-200"
|
? "cursor-not-allowed text-custom-text-200"
|
||||||
: "cursor-pointer hover:bg-custom-background-80"
|
: "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
|
onClick={() => {
|
||||||
|
openDropdown();
|
||||||
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
@ -86,26 +118,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu.Items className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Menu.Items className="fixed z-10" static>
|
||||||
className={`my-1 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
<div
|
||||||
maxHeight === "lg"
|
className={`my-1 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
||||||
? "max-h-60"
|
maxHeight === "lg"
|
||||||
: maxHeight === "md"
|
? "max-h-60"
|
||||||
? "max-h-48"
|
: maxHeight === "md"
|
||||||
: maxHeight === "rg"
|
? "max-h-48"
|
||||||
? "max-h-36"
|
: maxHeight === "rg"
|
||||||
: maxHeight === "sm"
|
? "max-h-36"
|
||||||
? "max-h-28"
|
: maxHeight === "sm"
|
||||||
: ""
|
? "max-h-28"
|
||||||
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
: ""
|
||||||
ref={setPopperElement}
|
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
{children}
|
>
|
||||||
</div>
|
{children}
|
||||||
</Menu.Items>
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
// react-popper
|
// react-popper
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
@ -29,11 +32,15 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||||||
optionsClassName = "",
|
optionsClassName = "",
|
||||||
value,
|
value,
|
||||||
width = "auto",
|
width = "auto",
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: placement ?? "bottom-start",
|
placement: placement ?? "bottom-start",
|
||||||
@ -50,8 +57,23 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||||||
|
|
||||||
if (multiple) comboboxProps.multiple = true;
|
if (multiple) comboboxProps.multiple = true;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox as="div" className={`relative flex-shrink-0 text-left ${className}`} {...comboboxProps}>
|
<Combobox
|
||||||
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={`relative flex-shrink-0 text-left ${className}`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...comboboxProps}
|
||||||
|
>
|
||||||
{({ open }: { open: boolean }) => {
|
{({ open }: { open: boolean }) => {
|
||||||
if (open && onOpen) onOpen();
|
if (open && onOpen) onOpen();
|
||||||
|
|
||||||
@ -67,6 +89,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||||||
? "cursor-not-allowed text-custom-text-200"
|
? "cursor-not-allowed text-custom-text-200"
|
||||||
: "cursor-pointer hover:bg-custom-background-80"
|
: "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${customButtonClassName}`}
|
} ${customButtonClassName}`}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{customButton}
|
{customButton}
|
||||||
</button>
|
</button>
|
||||||
@ -83,86 +106,89 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||||||
? "cursor-not-allowed text-custom-text-200"
|
? "cursor-not-allowed text-custom-text-200"
|
||||||
: "cursor-pointer hover:bg-custom-background-80"
|
: "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
)}
|
)}
|
||||||
<Combobox.Options as={React.Fragment}>
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options as={React.Fragment} static>
|
||||||
className={`z-10 my-1 min-w-[10rem] rounded-md border border-custom-border-300 bg-custom-background-90 p-2 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
|
||||||
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
|
|
||||||
} ${optionsClassName}`}
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={styles.popper}
|
|
||||||
{...attributes.popper}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
|
|
||||||
<Search className="h-3 w-3 text-custom-text-200" />
|
|
||||||
<Combobox.Input
|
|
||||||
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Type to search..."
|
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={`mt-2 space-y-1 ${
|
className={`z-10 my-1 min-w-[10rem] rounded-md border border-custom-border-300 bg-custom-background-90 p-2 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
||||||
maxHeight === "lg"
|
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
|
||||||
? "max-h-60"
|
} ${optionsClassName}`}
|
||||||
: maxHeight === "md"
|
ref={setPopperElement}
|
||||||
? "max-h-48"
|
style={styles.popper}
|
||||||
: maxHeight === "rg"
|
{...attributes.popper}
|
||||||
? "max-h-36"
|
|
||||||
: maxHeight === "sm"
|
|
||||||
? "max-h-28"
|
|
||||||
: ""
|
|
||||||
} overflow-y-scroll`}
|
|
||||||
>
|
>
|
||||||
{filteredOptions ? (
|
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
|
||||||
filteredOptions.length > 0 ? (
|
<Search className="h-3 w-3 text-custom-text-200" />
|
||||||
filteredOptions.map((option) => (
|
<Combobox.Input
|
||||||
<Combobox.Option
|
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
key={option.value}
|
value={query}
|
||||||
value={option.value}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className={({ active, selected }) =>
|
placeholder="Type to search..."
|
||||||
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
active || selected ? "bg-custom-background-80" : ""
|
/>
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
</div>
|
||||||
}
|
<div
|
||||||
>
|
className={`mt-2 space-y-1 ${
|
||||||
{({ active, selected }) => (
|
maxHeight === "lg"
|
||||||
<>
|
? "max-h-60"
|
||||||
{option.content}
|
: maxHeight === "md"
|
||||||
{multiple ? (
|
? "max-h-48"
|
||||||
<div
|
: maxHeight === "rg"
|
||||||
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${
|
? "max-h-36"
|
||||||
active || selected ? "opacity-100" : "opacity-0"
|
: maxHeight === "sm"
|
||||||
}`}
|
? "max-h-28"
|
||||||
>
|
: ""
|
||||||
|
} overflow-y-scroll`}
|
||||||
|
>
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
|
||||||
|
active || selected ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ active, selected }) => (
|
||||||
|
<>
|
||||||
|
{option.content}
|
||||||
|
{multiple ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${
|
||||||
|
active || selected ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||||
</div>
|
)}
|
||||||
) : (
|
</>
|
||||||
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
)}
|
||||||
)}
|
</Combobox.Option>
|
||||||
</>
|
))
|
||||||
)}
|
) : (
|
||||||
</Combobox.Option>
|
<span className="flex items-center gap-2 p-1">
|
||||||
))
|
<p className="text-left text-custom-text-200 ">No matching results</p>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2 p-1">
|
<p className="text-center text-custom-text-200">Loading...</p>
|
||||||
<p className="text-left text-custom-text-200 ">No matching results</p>
|
)}
|
||||||
</span>
|
</div>
|
||||||
)
|
{footerOption}
|
||||||
) : (
|
|
||||||
<p className="text-center text-custom-text-200">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{footerOption}
|
</Combobox.Options>
|
||||||
</div>
|
)}
|
||||||
</Combobox.Options>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
// react-popper
|
// react-popper
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Listbox } from "@headlessui/react";
|
import { Listbox } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
@ -26,20 +29,36 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
|||||||
optionsClassName = "",
|
optionsClassName = "",
|
||||||
value,
|
value,
|
||||||
width = "auto",
|
width = "auto",
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
|
// states
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: placement ?? "bottom-start",
|
placement: placement ?? "bottom-start",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Listbox
|
<Listbox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={`relative flex-shrink-0 text-left ${className}`}
|
className={`relative flex-shrink-0 text-left ${className}`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
@ -51,6 +70,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
|||||||
className={`flex items-center justify-between gap-1 text-xs ${
|
className={`flex items-center justify-between gap-1 text-xs ${
|
||||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${customButtonClassName}`}
|
} ${customButtonClassName}`}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{customButton}
|
{customButton}
|
||||||
</button>
|
</button>
|
||||||
@ -65,6 +85,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
|||||||
} ${
|
} ${
|
||||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||||
@ -72,26 +93,28 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
|||||||
</Listbox.Button>
|
</Listbox.Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
<Listbox.Options>
|
{isOpen && (
|
||||||
<div
|
<Listbox.Options static>
|
||||||
className={`z-10 my-1 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
<div
|
||||||
maxHeight === "lg"
|
className={`z-10 my-1 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
||||||
? "max-h-60"
|
maxHeight === "lg"
|
||||||
: maxHeight === "md"
|
? "max-h-60"
|
||||||
? "max-h-48"
|
: maxHeight === "md"
|
||||||
: maxHeight === "rg"
|
? "max-h-48"
|
||||||
? "max-h-36"
|
: maxHeight === "rg"
|
||||||
: maxHeight === "sm"
|
? "max-h-36"
|
||||||
? "max-h-28"
|
: maxHeight === "sm"
|
||||||
: ""
|
? "max-h-28"
|
||||||
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
: ""
|
||||||
ref={setPopperElement}
|
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="space-y-1 p-2">{children}</div>
|
>
|
||||||
</div>
|
<div className="space-y-1 p-2">{children}</div>
|
||||||
</Listbox.Options>
|
</div>
|
||||||
|
</Listbox.Options>
|
||||||
|
)}
|
||||||
</Listbox>
|
</Listbox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -15,6 +15,7 @@ export interface IDropdownProps {
|
|||||||
optionsClassName?: string;
|
optionsClassName?: string;
|
||||||
width?: "auto" | string;
|
width?: "auto" | string;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||||
|
24
packages/ui/src/hooks/use-dropdown-key-down.tsx
Normal file
24
packages/ui/src/hooks/use-dropdown-key-down.tsx
Normal 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;
|
||||||
|
};
|
19
packages/ui/src/hooks/use-outside-click-detector.tsx
Normal file
19
packages/ui/src/hooks/use-outside-click-detector.tsx
Normal 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;
|
@ -4,10 +4,11 @@ import { useRouter } from "next/router";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
import { Tab, Popover } from "@headlessui/react";
|
||||||
import { Control, Controller } from "react-hook-form";
|
import { Control, Controller } from "react-hook-form";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useWorkspace } from "hooks/store";
|
import { useApplication, useWorkspace } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
// hooks
|
// hooks
|
||||||
@ -38,13 +39,14 @@ type Props = {
|
|||||||
control: Control<any>;
|
control: Control<any>;
|
||||||
onChange: (data: string) => void;
|
onChange: (data: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
||||||
export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||||
const { label, value, control, onChange, disabled = false } = props;
|
const { label, value, control, onChange, disabled = false, tabIndex } = props;
|
||||||
// states
|
// states
|
||||||
const [image, setImage] = useState<File | null>(null);
|
const [image, setImage] = useState<File | null>(null);
|
||||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||||
@ -128,27 +130,27 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
|||||||
onChange(unsplashImages[0].urls.regular);
|
onChange(unsplashImages[0].urls.regular);
|
||||||
}, [value, onChange, unsplashImages]);
|
}, [value, onChange, unsplashImages]);
|
||||||
|
|
||||||
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
|
const openDropdown = () => setIsOpen(true);
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
|
||||||
|
useOutsideClickDetector(ref, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative z-[2]" ref={ref}>
|
<Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
|
||||||
<Popover.Button
|
<Popover.Button
|
||||||
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={openDropdown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Transition
|
|
||||||
show={isOpen}
|
{isOpen && (
|
||||||
enter="transition ease-out duration-100"
|
<Popover.Panel
|
||||||
enterFrom="transform opacity-0 scale-95"
|
className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm"
|
||||||
enterTo="transform opacity-100 scale-100"
|
static
|
||||||
leave="transition ease-in duration-75"
|
>
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm">
|
|
||||||
<div
|
<div
|
||||||
ref={imagePickerRef}
|
ref={imagePickerRef}
|
||||||
className="flex h-96 w-80 flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl md:h-[28rem] md:w-[36rem]"
|
className="flex h-96 w-80 flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl md:h-[28rem] md:w-[36rem]"
|
||||||
@ -349,7 +351,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
|||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
</Transition>
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -59,6 +59,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
setActiveProject(val);
|
setActiveProject(val);
|
||||||
}}
|
}}
|
||||||
buttonVariant="background-with-text"
|
buttonVariant="background-with-text"
|
||||||
|
tabIndex={7}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -89,6 +90,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
inputSize="md"
|
inputSize="md"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors?.name)}
|
hasError={Boolean(errors?.name)}
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -106,6 +108,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
hasError={Boolean(errors?.description)}
|
hasError={Boolean(errors?.description)}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
tabIndex={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -124,6 +127,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Start date"
|
placeholder="Start date"
|
||||||
maxDate={maxDate ?? undefined}
|
maxDate={maxDate ?? undefined}
|
||||||
|
tabIndex={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -140,6 +144,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="End date"
|
placeholder="End date"
|
||||||
minDate={minDate}
|
minDate={minDate}
|
||||||
|
tabIndex={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -149,10 +154,10 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
|
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={5}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={6}>
|
||||||
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
|
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useState } from "react";
|
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
|
|||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle } from "hooks/store";
|
import { useApplication, useCycle } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// icons
|
// icons
|
||||||
import { ContrastIcon } from "@plane/ui";
|
import { ContrastIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
@ -26,6 +28,7 @@ type Props = {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -104,9 +107,13 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
placement,
|
placement,
|
||||||
projectId,
|
projectId,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -166,15 +173,26 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedCycle = value ? getCycleById(value) : null;
|
const selectedCycle = value ? getCycleById(value) : null;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -182,6 +200,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -197,6 +216,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -241,53 +261,55 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Popover } from "@headlessui/react";
|
import { Popover } from "@headlessui/react";
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { CalendarDays, X } from "lucide-react";
|
import { CalendarDays, X } from "lucide-react";
|
||||||
// import "react-datepicker/dist/react-datepicker.css";
|
// import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
@ -25,6 +28,7 @@ type Props = {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
value: Date | string | null;
|
value: Date | string | null;
|
||||||
closeOnSelect?: boolean;
|
closeOnSelect?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -124,7 +128,11 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
placement,
|
placement,
|
||||||
value,
|
value,
|
||||||
closeOnSelect = true,
|
closeOnSelect = true,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -143,8 +151,16 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== "";
|
const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== "";
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="h-full flex-shrink-0">
|
<Popover ref={dropdownRef} tabIndex={tabIndex} className="h-full flex-shrink-0" onKeyDown={handleKeyDown}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<>
|
<>
|
||||||
<Popover.Button as={React.Fragment}>
|
<Popover.Button as={React.Fragment}>
|
||||||
@ -159,6 +175,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -220,22 +237,24 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Popover.Panel className="fixed z-10">
|
{isOpen && (
|
||||||
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
|
<Popover.Panel className="fixed z-10" static>
|
||||||
<DatePicker
|
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
|
||||||
selected={value ? new Date(value) : null}
|
<DatePicker
|
||||||
onChange={(val) => {
|
selected={value ? new Date(value) : null}
|
||||||
onChange(val);
|
onChange={(val) => {
|
||||||
if (closeOnSelect) close();
|
onChange(val);
|
||||||
}}
|
if (closeOnSelect) close();
|
||||||
dateFormat="dd-MM-yyyy"
|
}}
|
||||||
minDate={minDate}
|
dateFormat="dd-MM-yyyy"
|
||||||
maxDate={maxDate}
|
minDate={minDate}
|
||||||
calendarClassName="shadow-custom-shadow-rg rounded"
|
maxDate={maxDate}
|
||||||
inline
|
calendarClassName="shadow-custom-shadow-rg rounded"
|
||||||
/>
|
inline
|
||||||
</div>
|
/>
|
||||||
</Popover.Panel>
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useState } from "react";
|
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
@ -7,6 +7,8 @@ import { Check, ChevronDown, Search, Triangle } from "lucide-react";
|
|||||||
import sortBy from "lodash/sortBy";
|
import sortBy from "lodash/sortBy";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useEstimate } from "hooks/store";
|
import { useApplication, useEstimate } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
@ -24,6 +26,7 @@ type Props = {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -102,9 +105,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
placement,
|
placement,
|
||||||
projectId,
|
projectId,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -160,15 +167,26 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedEstimate = value !== null ? getEstimatePointValue(value) : null;
|
const selectedEstimate = value !== null ? getEstimatePointValue(value) : null;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -176,6 +194,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -191,6 +210,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -235,53 +255,55 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { Check, Search } from "lucide-react";
|
import { Check, Search } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useMember, useUser } from "hooks/store";
|
import { useApplication, useMember, useUser } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// components
|
// components
|
||||||
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
|
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
|
||||||
// icons
|
// icons
|
||||||
@ -33,9 +35,13 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|||||||
placement,
|
placement,
|
||||||
projectId,
|
projectId,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -93,12 +99,23 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|||||||
if (!projectMemberIds) fetchProjectMembers(workspaceSlug, projectId);
|
if (!projectMemberIds) fetchProjectMembers(workspaceSlug, projectId);
|
||||||
}, [fetchProjectMembers, projectId, projectMemberIds, workspaceSlug]);
|
}, [fetchProjectMembers, projectId, projectMemberIds, workspaceSlug]);
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
{...comboboxProps}
|
{...comboboxProps}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
@ -107,6 +124,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -122,6 +140,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -172,53 +191,55 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
1
web/components/dropdowns/member/types.d.ts
vendored
1
web/components/dropdowns/member/types.d.ts
vendored
@ -11,6 +11,7 @@ export type MemberDropdownProps = {
|
|||||||
dropdownArrow?: boolean;
|
dropdownArrow?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
|
tabIndex?: number;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
multiple: false;
|
multiple: false;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useState } from "react";
|
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
|
|||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useModule } from "hooks/store";
|
import { useApplication, useModule } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// icons
|
// icons
|
||||||
import { DiceIcon } from "@plane/ui";
|
import { DiceIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
@ -26,6 +28,7 @@ type Props = {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownOptions =
|
type DropdownOptions =
|
||||||
@ -104,9 +107,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
placement,
|
placement,
|
||||||
projectId,
|
projectId,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -166,15 +173,26 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedModule = value ? getModuleById(value) : null;
|
const selectedModule = value ? getModuleById(value) : null;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -182,6 +200,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -197,6 +216,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -241,53 +261,55 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { Fragment, ReactNode, useState } from "react";
|
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { Placement } from "@popperjs/core";
|
import { Placement } from "@popperjs/core";
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// icons
|
// icons
|
||||||
import { PriorityIcon } from "@plane/ui";
|
import { PriorityIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
@ -25,6 +28,7 @@ type Props = {
|
|||||||
onChange: (val: TIssuePriorities) => void;
|
onChange: (val: TIssuePriorities) => void;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
value: TIssuePriorities;
|
value: TIssuePriorities;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -210,9 +214,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
onChange,
|
onChange,
|
||||||
placement,
|
placement,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -269,15 +277,26 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
const filteredOptions =
|
const filteredOptions =
|
||||||
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -285,6 +304,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -300,6 +320,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -350,49 +371,51 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</Combobox.Options>
|
||||||
{filteredOptions.length > 0 ? (
|
)}
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment, ReactNode, useState } from "react";
|
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
|
|||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "hooks/store";
|
import { useProject } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
@ -24,6 +26,7 @@ type Props = {
|
|||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -99,9 +102,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
onChange,
|
onChange,
|
||||||
placement,
|
placement,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -146,15 +153,26 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedProject = value ? getProjectById(value) : null;
|
const selectedProject = value ? getProjectById(value) : null;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -162,6 +180,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -177,6 +196,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -221,53 +241,55 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment, ReactNode, useEffect, useState } from "react";
|
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core";
|
|||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useProjectState } from "hooks/store";
|
import { useApplication, useProjectState } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// icons
|
// icons
|
||||||
import { StateGroupIcon } from "@plane/ui";
|
import { StateGroupIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
@ -26,6 +28,7 @@ type Props = {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
@ -96,9 +99,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
placement,
|
placement,
|
||||||
projectId,
|
projectId,
|
||||||
value,
|
value,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper-js refs
|
// popper-js refs
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
@ -144,15 +151,26 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedState = getStateById(value);
|
const selectedState = getStateById(value);
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
className={cn("h-full flex-shrink-0", {
|
className={cn("h-full flex-shrink-0", {
|
||||||
className,
|
className,
|
||||||
})}
|
})}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Combobox.Button as={Fragment}>
|
<Combobox.Button as={Fragment}>
|
||||||
{button ? (
|
{button ? (
|
||||||
@ -160,6 +178,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</button>
|
</button>
|
||||||
@ -175,6 +194,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
},
|
},
|
||||||
buttonContainerClassName
|
buttonContainerClassName
|
||||||
)}
|
)}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
{buttonVariant === "border-with-text" ? (
|
{buttonVariant === "border-with-text" ? (
|
||||||
<BorderButton
|
<BorderButton
|
||||||
@ -219,53 +239,55 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
<Combobox.Options className="fixed z-10">
|
{isOpen && (
|
||||||
<div
|
<Combobox.Options className="fixed z-10" static>
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
<div
|
||||||
ref={setPopperElement}
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
style={styles.popper}
|
ref={setPopperElement}
|
||||||
{...attributes.popper}
|
style={styles.popper}
|
||||||
>
|
{...attributes.popper}
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<Combobox.Input
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
<Combobox.Input
|
||||||
value={query}
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
placeholder="Search"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
placeholder="Search"
|
||||||
/>
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
</div>
|
||||||
{filteredOptions ? (
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.length > 0 ? (
|
||||||
<Combobox.Option
|
filteredOptions.map((option) => (
|
||||||
key={option.value}
|
<Combobox.Option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
className={({ active, selected }) =>
|
value={option.value}
|
||||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
className={({ active, selected }) =>
|
||||||
active ? "bg-custom-background-80" : ""
|
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
active ? "bg-custom-background-80" : ""
|
||||||
}
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
>
|
}
|
||||||
{({ selected }) => (
|
>
|
||||||
<>
|
{({ selected }) => (
|
||||||
<span className="flex-grow truncate">{option.content}</span>
|
<>
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
<span className="flex-grow truncate">{option.content}</span>
|
||||||
</>
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
)}
|
</>
|
||||||
</Combobox.Option>
|
)}
|
||||||
))
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Combobox.Options>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
1
web/components/emoji-icon-picker/types.d.ts
vendored
1
web/components/emoji-icon-picker/types.d.ts
vendored
@ -11,4 +11,5 @@ export type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
onIconColorChange?: (data: any) => void;
|
onIconColorChange?: (data: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import React, { Fragment, useState } from "react";
|
import React, { Fragment, useRef, useState } from "react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover } from "@headlessui/react";
|
||||||
import { Placement } from "@popperjs/core";
|
import { Placement } from "@popperjs/core";
|
||||||
|
// hooks
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -12,20 +15,32 @@ type Props = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FiltersDropdown: React.FC<Props> = (props) => {
|
export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||||
const { children, title = "Dropdown", placement, disabled = false } = props;
|
const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props;
|
||||||
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: placement ?? "auto",
|
placement: placement ?? "auto",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover as="div">
|
<Popover as="div" ref={dropdownRef} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
}
|
}
|
||||||
@ -40,22 +55,15 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
|
|||||||
appendIcon={
|
appendIcon={
|
||||||
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
|
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
|
||||||
}
|
}
|
||||||
|
onClick={openDropdown}
|
||||||
>
|
>
|
||||||
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
|
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Transition
|
{isOpen && (
|
||||||
as={Fragment}
|
<Popover.Panel static>
|
||||||
enter="transition ease-out duration-200"
|
|
||||||
enterFrom="opacity-0 translate-y-1"
|
|
||||||
enterTo="opacity-100 translate-y-0"
|
|
||||||
leave="transition ease-in duration-150"
|
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
|
||||||
leaveTo="opacity-0 translate-y-1"
|
|
||||||
>
|
|
||||||
<Popover.Panel>
|
|
||||||
<div
|
<div
|
||||||
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||||
ref={setPopperElement}
|
ref={setPopperElement}
|
||||||
@ -65,7 +73,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
|
|||||||
<div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div>
|
<div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
</Transition>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -209,6 +209,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={19}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -238,6 +239,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
setSelectedParentIssue(null);
|
setSelectedParentIssue(null);
|
||||||
}}
|
}}
|
||||||
|
tabIndex={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -268,6 +270,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Issue Title"
|
placeholder="Issue Title"
|
||||||
className="resize-none text-xl w-full"
|
className="resize-none text-xl w-full"
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -281,6 +284,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}`}
|
}`}
|
||||||
onClick={handleAutoGenerateDescription}
|
onClick={handleAutoGenerateDescription}
|
||||||
disabled={iAmFeelingLucky}
|
disabled={iAmFeelingLucky}
|
||||||
|
tabIndex={3}
|
||||||
>
|
>
|
||||||
{iAmFeelingLucky ? (
|
{iAmFeelingLucky ? (
|
||||||
"Generating response"
|
"Generating response"
|
||||||
@ -309,6 +313,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||||
|
tabIndex={4}
|
||||||
>
|
>
|
||||||
<Sparkle className="h-4 w-4" />
|
<Sparkle className="h-4 w-4" />
|
||||||
AI
|
AI
|
||||||
@ -340,6 +345,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
mentionHighlights={mentionHighlights}
|
mentionHighlights={mentionHighlights}
|
||||||
mentionSuggestions={mentionSuggestions}
|
mentionSuggestions={mentionSuggestions}
|
||||||
|
// tabIndex={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -358,6 +364,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -374,6 +381,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={7}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -394,6 +402,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||||
placeholder="Assignees"
|
placeholder="Assignees"
|
||||||
multiple
|
multiple
|
||||||
|
tabIndex={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -411,6 +420,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
tabIndex={9}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -429,6 +439,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Start date"
|
placeholder="Start date"
|
||||||
maxDate={maxDate ?? undefined}
|
maxDate={maxDate ?? undefined}
|
||||||
|
tabIndex={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -447,6 +458,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Due date"
|
placeholder="Due date"
|
||||||
minDate={minDate ?? undefined}
|
minDate={minDate ?? undefined}
|
||||||
|
tabIndex={11}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -465,6 +477,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
value={value}
|
value={value}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -484,6 +497,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={13}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -503,6 +517,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -532,6 +547,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
tabIndex={15}
|
||||||
>
|
>
|
||||||
{watch("parent_id") ? (
|
{watch("parent_id") ? (
|
||||||
<>
|
<>
|
||||||
@ -578,6 +594,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
<div
|
<div
|
||||||
className="flex cursor-default items-center gap-1.5"
|
className="flex cursor-default items-center gap-1.5"
|
||||||
onClick={() => setCreateMore((prevData) => !prevData)}
|
onClick={() => setCreateMore((prevData) => !prevData)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") setCreateMore((prevData) => !prevData);
|
||||||
|
}}
|
||||||
|
tabIndex={16}
|
||||||
>
|
>
|
||||||
<div className="flex cursor-pointer items-center justify-center">
|
<div className="flex cursor-pointer items-center justify-center">
|
||||||
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
|
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
|
||||||
@ -585,10 +605,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
<span className="text-xs">Create more</span>
|
<span className="text-xs">Create more</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={17}>
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="sm" loading={isSubmitting}>
|
<Button type="submit" variant="primary" size="sm" loading={isSubmitting} tabIndex={18}>
|
||||||
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
|
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import React, { Fragment, useState } from "react";
|
import React, { Fragment, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Combobox, Transition } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useLabel } from "hooks/store";
|
import { useLabel } from "hooks/store";
|
||||||
|
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// ui
|
// ui
|
||||||
import { IssueLabelsList } from "components/ui";
|
import { IssueLabelsList } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -18,10 +20,11 @@ type Props = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
label?: JSX.Element;
|
label?: JSX.Element;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
||||||
const { setIsOpen, value, onChange, projectId, label, disabled = false } = props;
|
const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex } = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
@ -33,6 +36,9 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// popper
|
// popper
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
@ -46,86 +52,122 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
|||||||
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId) : null
|
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
setIsDropdownOpen(true);
|
||||||
|
if (referenceElement) referenceElement.focus();
|
||||||
|
};
|
||||||
|
const closeDropdown = () => setIsDropdownOpen(false);
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isDropdownOpen);
|
||||||
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
tabIndex={tabIndex}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(val) => onChange(val)}
|
onChange={(val) => onChange(val)}
|
||||||
className="relative flex-shrink-0 h-full"
|
className="relative flex-shrink-0 h-full"
|
||||||
multiple
|
multiple
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{({ open }: any) => (
|
<Combobox.Button as={Fragment}>
|
||||||
<>
|
<button
|
||||||
<Combobox.Button as={Fragment}>
|
type="button"
|
||||||
<button
|
ref={setReferenceElement}
|
||||||
type="button"
|
className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200"
|
||||||
ref={setReferenceElement}
|
onClick={openDropdown}
|
||||||
className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200"
|
>
|
||||||
>
|
{label ? (
|
||||||
{label ? (
|
label
|
||||||
label
|
) : value && value.length > 0 ? (
|
||||||
) : value && value.length > 0 ? (
|
<span className="flex items-center justify-center gap-2 text-xs">
|
||||||
<span className="flex items-center justify-center gap-2 text-xs">
|
<IssueLabelsList
|
||||||
<IssueLabelsList
|
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
|
||||||
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
|
length={3}
|
||||||
length={3}
|
showLength
|
||||||
showLength
|
/>
|
||||||
/>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
) : (
|
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
|
||||||
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
|
<Tag className="h-3 w-3 flex-shrink-0" />
|
||||||
<Tag className="h-3 w-3 flex-shrink-0" />
|
<span>Labels</span>
|
||||||
<span>Labels</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</button>
|
||||||
</button>
|
</Combobox.Button>
|
||||||
</Combobox.Button>
|
|
||||||
|
|
||||||
<Transition
|
{isDropdownOpen && (
|
||||||
show={open}
|
<Combobox.Options className="fixed z-10" static>
|
||||||
as={React.Fragment}
|
<div
|
||||||
enter="transition ease-out duration-200"
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300
|
||||||
enterFrom="opacity-0 translate-y-1"
|
bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
enterTo="opacity-100 translate-y-0"
|
ref={setPopperElement}
|
||||||
leave="transition ease-in duration-150"
|
style={styles.popper}
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
{...attributes.popper}
|
||||||
leaveTo="opacity-0 translate-y-1"
|
|
||||||
>
|
>
|
||||||
<Combobox.Options className="fixed z-10">
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
<div
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300
|
<Combobox.Input
|
||||||
bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
ref={setPopperElement}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
style={styles.popper}
|
placeholder="Search"
|
||||||
{...attributes.popper}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
</div>
|
||||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
<Combobox.Input
|
{projectLabels && filteredOptions ? (
|
||||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
filteredOptions.length > 0 ? (
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
filteredOptions.map((label) => {
|
||||||
placeholder="Search"
|
const children = projectLabels?.filter((l) => l.parent === label.id);
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
||||||
{projectLabels && filteredOptions ? (
|
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((label) => {
|
|
||||||
const children = projectLabels?.filter((l) => l.parent === label.id);
|
|
||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
if (!label.parent)
|
if (!label.parent)
|
||||||
return (
|
return (
|
||||||
|
<Combobox.Option
|
||||||
|
key={label.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
`${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||||
|
}
|
||||||
|
value={label.id}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<div className="flex w-full justify-between gap-2 rounded">
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{label.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center rounded p-1">
|
||||||
|
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
} else
|
||||||
|
return (
|
||||||
|
<div className="border-y border-custom-border-200">
|
||||||
|
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100">
|
||||||
|
<Component className="h-3 w-3" /> {label.name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{children.map((child) => (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={label.id}
|
key={child.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`${
|
`${
|
||||||
active ? "bg-custom-background-80" : ""
|
active ? "bg-custom-background-80" : ""
|
||||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||||
}
|
}
|
||||||
value={label.id}
|
value={child.id}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<div className="flex w-full justify-between gap-2 rounded">
|
<div className="flex w-full justify-between gap-2 rounded">
|
||||||
@ -133,10 +175,10 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
|||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: label.color,
|
backgroundColor: child?.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{label.name}</span>
|
<span>{child.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center rounded p-1">
|
<div className="flex items-center justify-center rounded p-1">
|
||||||
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||||
@ -144,65 +186,28 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
);
|
))}
|
||||||
} else
|
</div>
|
||||||
return (
|
</div>
|
||||||
<div className="border-y border-custom-border-200">
|
);
|
||||||
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100">
|
})
|
||||||
<Component className="h-3 w-3" /> {label.name}
|
) : (
|
||||||
</div>
|
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||||
<div>
|
)
|
||||||
{children.map((child) => (
|
) : (
|
||||||
<Combobox.Option
|
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||||
key={child.id}
|
)}
|
||||||
className={({ active }) =>
|
<button
|
||||||
`${
|
type="button"
|
||||||
active ? "bg-custom-background-80" : ""
|
className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80"
|
||||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
onClick={() => setIsOpen(true)}
|
||||||
}
|
>
|
||||||
value={child.id}
|
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||||
>
|
<span className="whitespace-nowrap">Create new label</span>
|
||||||
{({ selected }) => (
|
</button>
|
||||||
<div className="flex w-full justify-between gap-2 rounded">
|
</div>
|
||||||
<div className="flex items-center justify-start gap-2">
|
</div>
|
||||||
<span
|
</Combobox.Options>
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: child?.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{child.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center rounded p-1">
|
|
||||||
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
|
||||||
<span className="whitespace-nowrap">Create new label</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Combobox.Options>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
|
@ -93,6 +93,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
setActiveProject(val);
|
setActiveProject(val);
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -124,6 +125,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Module Title"
|
placeholder="Module Title"
|
||||||
className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400"
|
className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400"
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -141,6 +143,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
placeholder="Description..."
|
placeholder="Description..."
|
||||||
className="h-24 w-full resize-none text-sm"
|
className="h-24 w-full resize-none text-sm"
|
||||||
hasError={Boolean(errors?.description)}
|
hasError={Boolean(errors?.description)}
|
||||||
|
tabIndex={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -157,6 +160,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Start date"
|
placeholder="Start date"
|
||||||
maxDate={maxDate ?? undefined}
|
maxDate={maxDate ?? undefined}
|
||||||
|
tabIndex={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -172,11 +176,12 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Target date"
|
placeholder="Target date"
|
||||||
minDate={minDate ?? undefined}
|
minDate={minDate ?? undefined}
|
||||||
|
tabIndex={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ModuleStatusSelect control={control} error={errors.status} />
|
<ModuleStatusSelect control={control} error={errors.status} tabIndex={5} />
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="lead"
|
name="lead"
|
||||||
@ -189,6 +194,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
multiple={false}
|
multiple={false}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
placeholder="Lead"
|
placeholder="Lead"
|
||||||
|
tabIndex={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -206,6 +212,7 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"}
|
buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"}
|
||||||
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
|
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||||
placeholder="Members"
|
placeholder="Members"
|
||||||
|
tabIndex={7}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -214,10 +221,10 @@ export const ModuleForm: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 pt-5">
|
<div className="mt-5 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 pt-5">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={8}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={9}>
|
||||||
{status ? (isSubmitting ? "Updating" : "Update module") : isSubmitting ? "Creating" : "Create module"}
|
{status ? (isSubmitting ? "Updating" : "Update module") : isSubmitting ? "Creating" : "Create module"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,9 +12,10 @@ import { MODULE_STATUS } from "constants/module";
|
|||||||
type Props = {
|
type Props = {
|
||||||
control: Control<IModule, any>;
|
control: Control<IModule, any>;
|
||||||
error?: FieldError;
|
error?: FieldError;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
|
export const ModuleStatusSelect: React.FC<Props> = ({ control, error, tabIndex }) => (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
@ -35,6 +36,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
tabIndex={tabIndex}
|
||||||
noChevron
|
noChevron
|
||||||
>
|
>
|
||||||
{MODULE_STATUS.map((status) => (
|
{MODULE_STATUS.map((status) => (
|
||||||
|
@ -59,6 +59,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
|||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
className="w-full resize-none text-lg"
|
className="w-full resize-none text-lg"
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -72,7 +73,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
|
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
|
||||||
{PAGE_ACCESS_SPECIFIERS.map((access) => (
|
{PAGE_ACCESS_SPECIFIERS.map((access, index) => (
|
||||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -80,6 +81,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
|||||||
className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${
|
className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${
|
||||||
value === access.key ? "bg-custom-background-90" : ""
|
value === access.key ? "bg-custom-background-90" : ""
|
||||||
}`}
|
}`}
|
||||||
|
tabIndex={2 + index}
|
||||||
>
|
>
|
||||||
<access.icon
|
<access.icon
|
||||||
className={`h-3.5 w-3.5 ${
|
className={`h-3.5 w-3.5 ${
|
||||||
@ -98,10 +100,10 @@ export const PageForm: React.FC<Props> = (props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
|
||||||
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
|
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -226,7 +226,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute right-2 top-2 p-2">
|
<div className="absolute right-2 top-2 p-2">
|
||||||
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose}>
|
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={8}>
|
||||||
<X className="h-5 w-5 text-white" />
|
<X className="h-5 w-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -238,6 +238,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
control={control}
|
control={control}
|
||||||
value={watch("cover_image")}
|
value={watch("cover_image")}
|
||||||
|
tabIndex={9}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -bottom-[22px] left-3">
|
<div className="absolute -bottom-[22px] left-3">
|
||||||
@ -253,6 +254,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
}
|
}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
tabIndex={10}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -278,11 +280,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
tabIndex={1}
|
|
||||||
onChange={handleNameChange(onChange)}
|
onChange={handleNameChange(onChange)}
|
||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Project Title"
|
placeholder="Project Title"
|
||||||
className="w-full focus:border-blue-400"
|
className="w-full focus:border-blue-400"
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -313,11 +315,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
name="identifier"
|
name="identifier"
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
tabIndex={2}
|
|
||||||
onChange={handleIdentifierChange(onChange)}
|
onChange={handleIdentifierChange(onChange)}
|
||||||
hasError={Boolean(errors.identifier)}
|
hasError={Boolean(errors.identifier)}
|
||||||
placeholder="Identifier"
|
placeholder="Identifier"
|
||||||
className="w-full text-xs focus:border-blue-400 uppercase"
|
className="w-full text-xs focus:border-blue-400 uppercase"
|
||||||
|
tabIndex={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -332,11 +334,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
value={value}
|
value={value}
|
||||||
tabIndex={3}
|
|
||||||
placeholder="Description..."
|
placeholder="Description..."
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className="!h-24 text-sm focus:border-blue-400"
|
className="!h-24 text-sm focus:border-blue-400"
|
||||||
hasError={Boolean(errors?.description)}
|
hasError={Boolean(errors?.description)}
|
||||||
|
tabIndex={3}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -366,6 +368,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
}
|
}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
noChevron
|
noChevron
|
||||||
|
tabIndex={4}
|
||||||
>
|
>
|
||||||
{NETWORK_CHOICES.map((network) => (
|
{NETWORK_CHOICES.map((network) => (
|
||||||
<CustomSelect.Option
|
<CustomSelect.Option
|
||||||
@ -392,6 +395,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
|||||||
placeholder="Lead"
|
placeholder="Lead"
|
||||||
multiple={false}
|
multiple={false}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
|
tabIndex={5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -130,6 +130,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
|||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
className="w-full resize-none text-xl focus:border-blue-400"
|
className="w-full resize-none text-xl focus:border-blue-400"
|
||||||
|
tabIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -147,6 +148,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
|||||||
hasError={Boolean(errors?.description)}
|
hasError={Boolean(errors?.description)}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
tabIndex={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -156,7 +158,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="query_data"
|
name="query_data"
|
||||||
render={({ field: { onChange, value: filters } }) => (
|
render={({ field: { onChange, value: filters } }) => (
|
||||||
<FiltersDropdown title="Filters">
|
<FiltersDropdown title="Filters" tabIndex={3}>
|
||||||
<FilterSelection
|
<FilterSelection
|
||||||
filters={filters ?? {}}
|
filters={filters ?? {}}
|
||||||
handleFiltersUpdate={(key, value) => {
|
handleFiltersUpdate={(key, value) => {
|
||||||
@ -199,10 +201,10 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit">
|
<Button variant="primary" size="sm" type="submit" tabIndex={5}>
|
||||||
{data
|
{data
|
||||||
? isSubmitting
|
? isSubmitting
|
||||||
? "Updating View..."
|
? "Updating View..."
|
||||||
|
24
web/hooks/use-dropdown-key-down.tsx
Normal file
24
web/hooks/use-dropdown-key-down.tsx
Normal 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;
|
||||||
|
};
|
@ -1,95 +1,214 @@
|
|||||||
import { observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import isToday from "date-fns/isToday";
|
import isToday from "date-fns/isToday";
|
||||||
import isThisWeek from "date-fns/isThisWeek";
|
import isThisWeek from "date-fns/isThisWeek";
|
||||||
import isYesterday from "date-fns/isYesterday";
|
import isYesterday from "date-fns/isYesterday";
|
||||||
|
// services
|
||||||
import { IPage } from "@plane/types";
|
|
||||||
import { PageService } from "services/page.service";
|
import { PageService } from "services/page.service";
|
||||||
import { is } from "date-fns/locale";
|
// helpers
|
||||||
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { IPage, IRecentPages } from "@plane/types";
|
||||||
|
// store
|
||||||
|
import { RootStore } from "./root.store";
|
||||||
|
|
||||||
export interface IPageStore {
|
export interface IPageStore {
|
||||||
access: number;
|
pages: Record<string, IPage>;
|
||||||
archived_at: string | null;
|
archivedPages: Record<string, IPage>;
|
||||||
color: string;
|
// project computed
|
||||||
created_at: Date;
|
projectPageIds: string[] | null;
|
||||||
created_by: string;
|
favoriteProjectPageIds: string[] | null;
|
||||||
description: string;
|
privateProjectPageIds: string[] | null;
|
||||||
description_html: string;
|
publicProjectPageIds: string[] | null;
|
||||||
description_stripped: string | null;
|
archivedProjectPageIds: string[] | null;
|
||||||
id: string;
|
recentProjectPages: IRecentPages | null;
|
||||||
is_favorite: boolean;
|
// fetch page information actions
|
||||||
is_locked: boolean;
|
getUnArchivedPageById: (pageId: string) => IPage | null;
|
||||||
labels: string[];
|
getArchivedPageById: (pageId: string) => IPage | null;
|
||||||
name: string;
|
// fetch actions
|
||||||
owned_by: string;
|
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||||
project: string;
|
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||||
updated_at: Date;
|
// favorites actions
|
||||||
updated_by: string;
|
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
workspace: string;
|
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
// crud
|
||||||
|
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||||
|
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||||
|
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
// access control actions
|
||||||
|
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
// archive actions
|
||||||
|
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageStore {
|
export class PageStore implements IPageStore {
|
||||||
access: number;
|
pages: Record<string, IPage> = {};
|
||||||
archived_at: string | null;
|
archivedPages: Record<string, IPage> = {};
|
||||||
color: string;
|
// services
|
||||||
created_at: Date;
|
|
||||||
created_by: string;
|
|
||||||
description: string;
|
|
||||||
description_html: string;
|
|
||||||
description_stripped: string | null;
|
|
||||||
id: string;
|
|
||||||
is_favorite: boolean;
|
|
||||||
is_locked: boolean;
|
|
||||||
labels: string[];
|
|
||||||
name: string;
|
|
||||||
owned_by: string;
|
|
||||||
project: string;
|
|
||||||
updated_at: Date;
|
|
||||||
updated_by: string;
|
|
||||||
workspace: string;
|
|
||||||
|
|
||||||
pageService;
|
pageService;
|
||||||
|
// stores
|
||||||
|
rootStore;
|
||||||
|
|
||||||
constructor(page: IPage) {
|
constructor(rootStore: RootStore) {
|
||||||
observable(this, {
|
makeObservable(this, {
|
||||||
name: observable.ref,
|
pages: observable,
|
||||||
description_html: observable.ref,
|
archivedPages: observable,
|
||||||
is_favorite: observable.ref,
|
// computed
|
||||||
is_locked: observable.ref,
|
projectPageIds: computed,
|
||||||
|
favoriteProjectPageIds: computed,
|
||||||
|
publicProjectPageIds: computed,
|
||||||
|
privateProjectPageIds: computed,
|
||||||
|
archivedProjectPageIds: computed,
|
||||||
|
recentProjectPages: computed,
|
||||||
|
// computed actions
|
||||||
|
getUnArchivedPageById: action,
|
||||||
|
getArchivedPageById: action,
|
||||||
|
// fetch actions
|
||||||
|
fetchProjectPages: action,
|
||||||
|
fetchArchivedProjectPages: action,
|
||||||
|
// favorites actions
|
||||||
|
addToFavorites: action,
|
||||||
|
removeFromFavorites: action,
|
||||||
|
// crud
|
||||||
|
createPage: action,
|
||||||
|
updatePage: action,
|
||||||
|
deletePage: action,
|
||||||
|
// access control actions
|
||||||
|
makePublic: action,
|
||||||
|
makePrivate: action,
|
||||||
|
// archive actions
|
||||||
|
archivePage: action,
|
||||||
|
restorePage: action,
|
||||||
});
|
});
|
||||||
this.created_by = page?.created_by || "";
|
// stores
|
||||||
this.created_at = page?.created_at || new Date();
|
this.rootStore = rootStore;
|
||||||
this.color = page?.color || "";
|
// services
|
||||||
this.archived_at = page?.archived_at || null;
|
|
||||||
this.name = page?.name || "";
|
|
||||||
this.description = page?.description || "";
|
|
||||||
this.description_stripped = page?.description_stripped || "";
|
|
||||||
this.description_html = page?.description_html || "";
|
|
||||||
this.access = page?.access || 0;
|
|
||||||
this.workspace = page?.workspace || "";
|
|
||||||
this.updated_by = page?.updated_by || "";
|
|
||||||
this.updated_at = page?.updated_at || new Date();
|
|
||||||
this.project = page?.project || "";
|
|
||||||
this.owned_by = page?.owned_by || "";
|
|
||||||
this.labels = page?.labels || [];
|
|
||||||
this.is_locked = page?.is_locked || false;
|
|
||||||
this.id = page?.id || "";
|
|
||||||
this.is_favorite = page?.is_favorite || false;
|
|
||||||
|
|
||||||
this.pageService = new PageService();
|
this.pageService = new PageService();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateName = async (name: string) => {
|
/**
|
||||||
this.name = name;
|
* retrieves all pages for a projectId that is available in the url.
|
||||||
await this.pageService.patchPage(this.workspace, this.project, this.id, { name });
|
*/
|
||||||
};
|
get projectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!projectId) return null;
|
||||||
|
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId);
|
||||||
|
return projectPageIds ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
updateDescription = async (description: string) => {
|
/**
|
||||||
this.description = description;
|
* retrieves all favorite pages for a projectId that is available in the url.
|
||||||
await this.pageService.patchPage(this.workspace, this.project, this.id, { description });
|
*/
|
||||||
};
|
get favoriteProjectPageIds() {
|
||||||
|
if (!this.projectPageIds) return null;
|
||||||
|
const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite);
|
||||||
|
return favoritePagesIds ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves all private pages for a projectId that is available in the url.
|
||||||
|
*/
|
||||||
|
get privateProjectPageIds() {
|
||||||
|
if (!this.projectPageIds) return null;
|
||||||
|
const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1);
|
||||||
|
return privatePagesIds ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url.
|
||||||
|
*/
|
||||||
|
get publicProjectPageIds() {
|
||||||
|
if (!this.projectPageIds) return null;
|
||||||
|
const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0);
|
||||||
|
return publicPagesIds ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves all recent pages for a projectId that is available in the url.
|
||||||
|
* In format where today, yesterday, this_week, older are keys.
|
||||||
|
*/
|
||||||
|
get recentProjectPages() {
|
||||||
|
if (!this.projectPageIds) return null;
|
||||||
|
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
|
||||||
|
data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.created_at))) || [];
|
||||||
|
data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.created_at))) || [];
|
||||||
|
data.this_week =
|
||||||
|
this.projectPageIds.filter((p) => {
|
||||||
|
const pageCreatedAt = this.pages?.[p]?.created_at;
|
||||||
|
return (
|
||||||
|
isThisWeek(new Date(pageCreatedAt)) &&
|
||||||
|
!isToday(new Date(pageCreatedAt)) &&
|
||||||
|
!isYesterday(new Date(pageCreatedAt))
|
||||||
|
);
|
||||||
|
}) || [];
|
||||||
|
data.older =
|
||||||
|
this.projectPageIds.filter((p) => {
|
||||||
|
const pageCreatedAt = this.pages?.[p]?.created_at;
|
||||||
|
return !isThisWeek(new Date(pageCreatedAt)) && !isYesterday(new Date(pageCreatedAt));
|
||||||
|
}) || [];
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves all archived pages for a projectId that is available in the url.
|
||||||
|
*/
|
||||||
|
get archivedProjectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!projectId) return null;
|
||||||
|
const archivedProjectPageIds = Object.keys(this.archivedPages).filter(
|
||||||
|
(pageId) => this.archivedPages?.[pageId]?.project === projectId
|
||||||
|
);
|
||||||
|
return archivedProjectPageIds ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves a page from pages by id.
|
||||||
|
* @param pageId
|
||||||
|
* @returns IPage | null
|
||||||
|
*/
|
||||||
|
getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieves a page from archived pages by id.
|
||||||
|
* @param pageId
|
||||||
|
* @returns IPage | null
|
||||||
|
*/
|
||||||
|
getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetches all pages for a project.
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @returns Promise<IPage[]>
|
||||||
|
*/
|
||||||
|
fetchProjectPages = async (workspaceSlug: string, projectId: string) =>
|
||||||
|
await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
|
||||||
|
runInAction(() => {
|
||||||
|
response.forEach((page) => {
|
||||||
|
set(this.pages, [page.id], page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetches all archived pages for a project.
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @returns Promise<IPage[]>
|
||||||
|
*/
|
||||||
|
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
||||||
|
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||||
|
runInAction(() => {
|
||||||
|
response.forEach((page) => {
|
||||||
|
set(this.archivedPages, [page.id], page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Page to users favorites list
|
* Add Page to users favorites list
|
||||||
@ -100,11 +219,13 @@ export class PageStore {
|
|||||||
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
try {
|
try {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.is_favorite = true;
|
set(this.pages, [pageId, "is_favorite"], true);
|
||||||
});
|
});
|
||||||
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
|
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.is_favorite = false;
|
runInAction(() => {
|
||||||
|
set(this.pages, [pageId, "is_favorite"], false);
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -117,13 +238,62 @@ export class PageStore {
|
|||||||
*/
|
*/
|
||||||
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
try {
|
try {
|
||||||
this.is_favorite = false;
|
runInAction(() => {
|
||||||
|
set(this.pages, [pageId, "is_favorite"], false);
|
||||||
|
});
|
||||||
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
|
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.is_favorite = true;
|
runInAction(() => {
|
||||||
|
set(this.pages, [pageId, "is_favorite"], true);
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Creates a new page using the api and updated the local state in store
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) =>
|
||||||
|
await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.pages, [response.id], response);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updates the page using the api and updates the local state in store
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @param pageId
|
||||||
|
* @param data
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) =>
|
||||||
|
await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => {
|
||||||
|
const originalPage = this.getUnArchivedPageById(pageId);
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.pages, [pageId], { ...originalPage, ...data });
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete a page using the api and updates the local state in store
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @param pageId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
||||||
|
await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => {
|
||||||
|
runInAction(() => {
|
||||||
|
omit(this.archivedPages, [pageId]);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* make a page public
|
* make a page public
|
||||||
@ -135,12 +305,12 @@ export class PageStore {
|
|||||||
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
try {
|
try {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.access = 0;
|
set(this.pages, [pageId, "access"], 0);
|
||||||
});
|
});
|
||||||
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
|
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.access = 1;
|
set(this.pages, [pageId, "access"], 1);
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -156,14 +326,43 @@ export class PageStore {
|
|||||||
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
try {
|
try {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.access = 1;
|
set(this.pages, [pageId, "access"], 1);
|
||||||
});
|
});
|
||||||
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
|
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.access = 0;
|
set(this.pages, [pageId, "access"], 0);
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a page archived
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @param pageId
|
||||||
|
*/
|
||||||
|
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
||||||
|
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.archivedPages, [pageId], this.pages[pageId]);
|
||||||
|
set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date()));
|
||||||
|
omit(this.pages, [pageId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a page from archived pages to pages
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @param pageId
|
||||||
|
*/
|
||||||
|
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
||||||
|
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.pages, [pageId], this.archivedPages[pageId]);
|
||||||
|
omit(this.archivedPages, [pageId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
||||||
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
|
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.projectPages[projectId] = response?.map((page) => new PageStore(page));
|
this.projectPages[projectId] = response?.map((page) => new PageStore(page as any));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
||||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page));
|
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
@ -73,7 +73,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
||||||
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response)];
|
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)];
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
@ -91,7 +91,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
this.projectPages = set(
|
this.projectPages = set(
|
||||||
this.projectPages,
|
this.projectPages,
|
||||||
[projectId],
|
[projectId],
|
||||||
this.projectPages[projectId].filter((page) => page.id !== pageId)
|
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
@ -109,7 +109,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
set(
|
set(
|
||||||
this.projectPages,
|
this.projectPages,
|
||||||
[projectId],
|
[projectId],
|
||||||
this.projectPages[projectId].filter((page) => page.id !== pageId)
|
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
||||||
);
|
);
|
||||||
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
|
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
|
||||||
});
|
});
|
||||||
@ -128,7 +128,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
set(
|
set(
|
||||||
this.projectArchivedPages,
|
this.projectArchivedPages,
|
||||||
[projectId],
|
[projectId],
|
||||||
this.projectArchivedPages[projectId].filter((page) => page.id !== pageId)
|
this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
|
||||||
);
|
);
|
||||||
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
|
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
|
||||||
});
|
});
|
||||||
|
@ -32,7 +32,7 @@ export class RootStore {
|
|||||||
module: IModuleStore;
|
module: IModuleStore;
|
||||||
projectView: IProjectViewStore;
|
projectView: IProjectViewStore;
|
||||||
globalView: IGlobalViewStore;
|
globalView: IGlobalViewStore;
|
||||||
// page: IPageStore;
|
page: IPageStore;
|
||||||
issue: IIssueRootStore;
|
issue: IIssueRootStore;
|
||||||
state: IStateStore;
|
state: IStateStore;
|
||||||
estimate: IEstimateStore;
|
estimate: IEstimateStore;
|
||||||
@ -57,5 +57,6 @@ export class RootStore {
|
|||||||
this.estimate = new EstimateStore(this);
|
this.estimate = new EstimateStore(this);
|
||||||
this.mention = new MentionStore(this);
|
this.mention = new MentionStore(this);
|
||||||
this.projectPages = new ProjectPageStore();
|
this.projectPages = new ProjectPageStore();
|
||||||
|
this.page = new PageStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user