feat: Keyboard navigation spreadsheet layout for issues (#3564)

* enable keyboard navigation for spreadsheet layout

* move the logic to table level instead of cell level

* fix perf issue that made it unusable

* fix scroll issue with navigation

* fix build errors
This commit is contained in:
rahulramesha 2024-02-08 11:49:00 +05:30 committed by GitHub
parent a43dfc097d
commit fb3dd77b66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 368 additions and 126 deletions

View File

@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const { const {
buttonClassName = "", buttonClassName = "",
customButtonClassName = "", customButtonClassName = "",
customButtonTabIndex = 0,
placement, placement,
children, children,
className = "", className = "",
@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
verticalEllipsis = false, verticalEllipsis = false,
portalElement, portalElement,
menuButtonOnClick, menuButtonOnClick,
onMenuClose,
tabIndex, tabIndex,
closeOnSelect, closeOnSelect,
} = props; } = props;
@ -47,18 +49,27 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
setIsOpen(true); setIsOpen(true);
if (referenceElement) referenceElement.focus(); if (referenceElement) referenceElement.focus();
}; };
const closeDropdown = () => setIsOpen(false); const closeDropdown = () => {
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); isOpen && onMenuClose && onMenuClose();
setIsOpen(false);
};
const handleOnChange = () => {
if (closeOnSelect) closeDropdown();
};
const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button`
);
activeItem?.click();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
useOutsideClickDetector(dropdownRef, closeDropdown); useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = ( let menuItems = (
<Menu.Items <Menu.Items className="fixed z-10" static>
className="fixed z-10"
onClick={() => {
if (closeOnSelect) closeDropdown();
}}
static
>
<div <div
className={cn( className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap", "my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@ -89,7 +100,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
ref={dropdownRef} ref={dropdownRef}
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("relative w-min text-left", className)} className={cn("relative w-min text-left", className)}
onKeyDown={handleKeyDown} onKeyDownCapture={handleKeyDown}
onChange={handleOnChange}
> >
{({ open }) => ( {({ open }) => (
<> <>
@ -103,6 +115,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
className={customButtonClassName} className={customButtonClassName}
tabIndex={customButtonTabIndex}
> >
{customButton} {customButton}
</button> </button>
@ -122,6 +135,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
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"
} ${buttonClassName}`} } ${buttonClassName}`}
tabIndex={customButtonTabIndex}
> >
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} /> <MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
</button> </button>
@ -142,6 +156,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
tabIndex={customButtonTabIndex}
> >
{label} {label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />} {!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@ -159,6 +174,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, onClick, className = "" } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div">
{({ active, close }) => ( {({ active, close }) => (

View File

@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps { export interface IDropdownProps {
customButtonClassName?: string; customButtonClassName?: string;
customButtonTabIndex?: number;
buttonClassName?: string; buttonClassName?: string;
className?: string; className?: string;
customButton?: JSX.Element; customButton?: JSX.Element;
@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
noBorder?: boolean; noBorder?: boolean;
verticalEllipsis?: boolean; verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void; menuButtonOnClick?: (...args: any) => void;
onMenuClose?: () => void;
closeOnSelect?: boolean; closeOnSelect?: boolean;
portalElement?: Element | null; portalElement?: Element | null;
} }

View File

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

View File

@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props; const { href, label, icon } = props;
return ( return (
<Tooltip tooltipContent={label} position="bottom"> <Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2"> <li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{href ? ( {href ? (
<Link <Link

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string | null) => void; onChange: (val: string | null) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: string | null; value: string | null;
}; };
@ -47,6 +48,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Cycle", placeholder = "Cycle",
placement, placement,
projectId, projectId,
@ -123,8 +125,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -163,7 +167,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
minDate?: Date; minDate?: Date;
maxDate?: Date; maxDate?: Date;
onChange: (val: Date | null) => void; onChange: (val: Date | null) => void;
onClose?: () => void;
value: Date | string | null; value: Date | string | null;
closeOnSelect?: boolean; closeOnSelect?: boolean;
}; };
@ -42,6 +43,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
minDate, minDate,
maxDate, maxDate,
onChange, onChange,
onClose,
placeholder = "Date", placeholder = "Date",
placement, placement,
showTooltip = false, showTooltip = false,
@ -74,8 +76,10 @@ export const DateDropdown: React.FC<Props> = (props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -112,7 +116,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: number | null) => void; onChange: (val: number | null) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: number | null; value: number | null;
}; };
@ -46,6 +47,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Estimate", placeholder = "Estimate",
placement, placement,
projectId, projectId,
@ -112,8 +114,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -152,7 +156,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -162,7 +166,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -21,6 +21,7 @@ import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
type Props = { type Props = {
projectId: string; projectId: string;
onClose?: () => void;
} & MemberDropdownProps; } & MemberDropdownProps;
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => { export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
@ -36,6 +37,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Members", placeholder = "Members",
placement, placement,
projectId, projectId,
@ -105,8 +107,10 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -144,7 +148,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -154,7 +158,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
placeholder?: string; placeholder?: string;
onClose?: () => void;
} & ( } & (
| { | {
multiple: false; multiple: false;

View File

@ -32,6 +32,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Members", placeholder = "Members",
placement, placement,
showTooltip = false, showTooltip = false,
@ -95,8 +96,10 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -134,7 +137,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -144,7 +147,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -24,6 +24,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
projectId: string; projectId: string;
showCount?: boolean; showCount?: boolean;
onClose?: () => void;
} & ( } & (
| { | {
multiple: false; multiple: false;
@ -151,6 +152,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Module", placeholder = "Module",
placement, placement,
projectId, projectId,
@ -226,8 +228,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -271,7 +275,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -281,7 +285,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
highlightUrgent?: boolean; highlightUrgent?: boolean;
onChange: (val: TIssuePriorities) => void; onChange: (val: TIssuePriorities) => void;
onClose?: () => void;
value: TIssuePriorities; value: TIssuePriorities;
}; };
@ -260,6 +261,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
hideIcon = false, hideIcon = false,
highlightUrgent = true, highlightUrgent = true,
onChange, onChange,
onClose,
placement, placement,
showTooltip = false, showTooltip = false,
tabIndex, tabIndex,
@ -308,8 +310,10 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -360,7 +364,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -370,7 +374,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onClose?: () => void;
value: string | null; value: string | null;
}; };
@ -37,6 +38,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Project", placeholder = "Project",
placement, placement,
showTooltip = false, showTooltip = false,
@ -97,7 +99,9 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
}; };
@ -137,7 +141,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -147,7 +151,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: string; value: string;
}; };
@ -39,6 +40,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placement, placement,
projectId, projectId,
showTooltip = false, showTooltip = false,
@ -94,7 +96,9 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
}; };
@ -134,7 +138,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -144,7 +148,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -4,6 +4,7 @@ import { usePopper } from "react-popper";
import { Check, ChevronDown, Search, Tags } from "lucide-react"; import { Check, ChevronDown, Search, Tags } from "lucide-react";
// hooks // hooks
import { useApplication, useLabel } from "hooks/store"; import { useApplication, useLabel } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// components // components
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
@ -25,6 +26,7 @@ export interface IIssuePropertyLabels {
maxRender?: number; maxRender?: number;
noLabelBorder?: boolean; noLabelBorder?: boolean;
placeholderText?: string; placeholderText?: string;
onClose?: () => void;
} }
export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((props) => { export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((props) => {
@ -33,6 +35,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value, value,
defaultOptions = [], defaultOptions = [],
onChange, onChange,
onClose,
disabled, disabled,
hideDropdownArrow = false, hideDropdownArrow = false,
className, className,
@ -64,6 +67,12 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
} }
}; };
const handleClose = () => {
onClose && onClose();
};
const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start", placement: placement ?? "bottom-start",
modifiers: [ modifiers: [
@ -171,13 +180,14 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
onKeyDownCapture={handleKeyDown}
multiple multiple
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={`flex w-full items-center justify-between gap-1 text-xs ${ className={`clickable flex w-full items-center justify-between gap-1 text-xs ${
disabled disabled
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender : value.length <= maxRender
@ -205,7 +215,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search" placeholder="Search"
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name || ""}
/> />
</div> </div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}> <div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
@ -216,10 +226,10 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
<Combobox.Option <Combobox.Option
key={option.value} key={option.value}
value={option.value} value={option.value}
className={({ selected }) => className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
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 }) => (

View File

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props
} }
buttonClassName="text-left" buttonClassName="text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -36,6 +37,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left" buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -6,12 +6,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left" buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -20,10 +20,11 @@ interface Props {
property: keyof IIssueDisplayProperties; property: keyof IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions; displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void; handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
onClose: () => void;
} }
export const SpreadsheetHeaderColumn = (props: Props) => { export const HeaderColumn = (props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property } = props; const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props;
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
"spreadsheetViewSorting", "spreadsheetViewSorting",
@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
return ( return (
<CustomMenu <CustomMenu
customButtonClassName="!w-full" customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full" className="!w-full"
customButton={ customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100"> <div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
</div> </div>
</div> </div>
} }
onMenuClose={onClose}
placement="bottom-end" placement="bottom-end"
> >
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}> <CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
// hooks // hooks
const { labelMap } = useLabel(); const { labelMap } = useLabel();
@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
projectId={issue.project_id ?? null} projectId={issue.project_id ?? null}
value={issue.label_ids} value={issue.label_ids}
defaultOptions={defaultLabelOptions} defaultOptions={defaultLabelOptions}
onChange={(data) => onChange(issue, { label_ids: data },{ changed_property: "labels", change_details: data })} onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })}
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
buttonClassName="px-2.5 h-full" buttonClassName="px-2.5 h-full"
hideDropdownArrow hideDropdownArrow
maxRender={1} maxRender={1}
disabled={disabled} disabled={disabled}
placeholderText="Select labels" placeholderText="Select labels"
onClose={onClose}
/> />
); );
}); });

View File

@ -7,22 +7,24 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>,updates:any) => void; onChange: (issue: TIssue, data: Partial<TIssue>,updates:any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetPriorityColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetPriorityColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
<PriorityDropdown <PriorityDropdown
value={issue.priority} value={issue.priority}
onChange={(data) => onChange(issue, { priority: data },{changed_property:"priority",change_details:data})} onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })}
disabled={disabled} disabled={disabled}
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left" buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Props) => { export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -36,6 +37,7 @@ export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Prop
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left" buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}; };
export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => { export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
const { issue, onChange, disabled } = props; const { issue, onChange, disabled, onClose } = props;
return ( return (
<div className="h-11 border-b-[0.5px] border-custom-border-200"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
buttonVariant="transparent-with-text" buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left" buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full" buttonContainerClassName="w-full"
onClose={onClose}
/> />
</div> </div>
); );

View File

@ -0,0 +1,68 @@
import { useRef } from "react";
import { useRouter } from "next/router";
// types
import { IIssueDisplayProperties, TIssue } from "@plane/types";
import { EIssueActions } from "../types";
// constants
import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { useEventTracker } from "hooks/store";
import { observer } from "mobx-react";
type Props = {
displayProperties: IIssueDisplayProperties;
issueDetail: TIssue;
disableUserActions: boolean;
property: keyof IIssueDisplayProperties;
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
isEstimateEnabled: boolean;
};
export const IssueColumn = observer((props: Props) => {
const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props;
// router
const router = useRouter();
const tableCellRef = useRef<HTMLTableCellElement | null>(null);
const { captureIssueEvent } = useEventTracker();
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<td
tabIndex={0}
className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100 focus:border-custom-primary-70"
ref={tableCellRef}
>
<Column
issue={issueDetail}
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
captureIssueEvent({
eventName: "Issue updated",
payload: {
...issue,
...data,
element: "Spreadsheet layout",
},
updates: updates,
path: router.asPath,
});
})
}
disabled={disableUserActions}
onClose={() => {
tableCellRef?.current?.focus();
}}
/>
</td>
</WithDisplayPropertiesHOC>
);
});

View File

@ -4,14 +4,15 @@ import { observer } from "mobx-react-lite";
// icons // icons
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
// constants // constants
import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components // components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssueColumn } from "./issue-column";
// ui // ui
import { ControlLink, Tooltip } from "@plane/ui"; import { ControlLink, Tooltip } from "@plane/ui";
// hooks // hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useEventTracker, useIssueDetail, useProject } from "hooks/store"; import { useIssueDetail, useProject } from "hooks/store";
// helper // helper
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
// types // types
@ -51,7 +52,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
//hooks //hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { peekIssue, setPeekIssue } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// states // states
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
const [isExpanded, setExpanded] = useState<boolean>(false); const [isExpanded, setExpanded] = useState<boolean>(false);
@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
{/* first column/ issue name and key column */} {/* first column/ issue name and key column */}
<td <td
className={cn( className={cn(
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200", "sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200 focus:border-custom-primary-70",
{ {
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id, "border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
} }
)} )}
tabIndex={0}
> >
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<div <div
@ -149,11 +150,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`} href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issueDetail)} onClick={() => handleIssuePeekOverview(issueDetail)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
> >
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}> <Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"> <div
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
tabIndex={-1}
>
{issueDetail.name} {issueDetail.name}
</div> </div>
</Tooltip> </Tooltip>
@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
</ControlLink> </ControlLink>
</td> </td>
{/* Rest of the columns */} {/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => { {SPREADSHEET_PROPERTY_LIST.map((property) => (
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; <IssueColumn
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties} displayProperties={displayProperties}
displayPropertyKey={property} issueDetail={issueDetail}
shouldRenderProperty={shouldRenderProperty} disableUserActions={disableUserActions}
> property={property}
<td className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100"> handleIssues={handleIssues}
<Column isEstimateEnabled={isEstimateEnabled}
issue={issueDetail}
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
captureIssueEvent({
eventName: "Issue updated",
payload: {
...issue,
...data,
element: "Spreadsheet layout",
},
updates: updates,
path: router.asPath,
});
})
}
disabled={disableUserActions}
/> />
</td> ))}
</WithDisplayPropertiesHOC>
);
})}
</tr> </tr>
{isExpanded && {isExpanded &&

View File

@ -0,0 +1,46 @@
import { useRef } from "react";
//types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
//components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { HeaderColumn } from "./columns/header-column";
import { observer } from "mobx-react";
interface Props {
displayProperties: IIssueDisplayProperties;
property: keyof IIssueDisplayProperties;
isEstimateEnabled: boolean;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
}
export const SpreadsheetHeaderColumn = observer((props: Props) => {
const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props;
//hooks
const tableHeaderCellRef = useRef<HTMLTableCellElement | null>(null);
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<th
className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100 focus:border-custom-primary-70"
ref={tableHeaderCellRef}
tabIndex={0}
>
<HeaderColumn
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property}
onClose={() => {
tableHeaderCellRef?.current?.focus();
}}
/>
</th>
</WithDisplayPropertiesHOC>
);
});

View File

@ -6,8 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/type
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components // components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { SpreadsheetHeaderColumn } from "./columns/header-column"; import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column";
interface Props { interface Props {
displayProperties: IIssueDisplayProperties; displayProperties: IIssueDisplayProperties;
@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => {
return ( return (
<thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100"> <thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100">
<tr> <tr>
<th className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"> <th
className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"
tabIndex={-1}
>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<span className="flex h-full w-24 flex-shrink-0 items-center px-4 py-2.5"> <span className="flex h-full w-24 flex-shrink-0 items-center px-4 py-2.5">
<span className="mr-1.5 text-custom-text-400">#</span>ID <span className="mr-1.5 text-custom-text-400">#</span>ID
@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => {
</span> </span>
</th> </th>
{SPREADSHEET_PROPERTY_LIST.map((property) => { {SPREADSHEET_PROPERTY_LIST.map((property) => (
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<th className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100">
<SpreadsheetHeaderColumn <SpreadsheetHeaderColumn
property={property}
displayProperties={displayProperties}
displayFilters={displayFilters} displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate} handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property} isEstimateEnabled={isEstimateEnabled}
/> />
</th> ))}
</WithDisplayPropertiesHOC>
);
})}
</tr> </tr>
</thead> </thead>
); );

View File

@ -5,6 +5,7 @@ import { EIssueActions } from "../types";
//components //components
import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetIssueRow } from "./issue-row";
import { SpreadsheetHeader } from "./spreadsheet-header"; import { SpreadsheetHeader } from "./spreadsheet-header";
import { useTableKeyboardNavigation } from "hooks/use-table-keyboard-navigation";
type Props = { type Props = {
displayProperties: IIssueDisplayProperties; displayProperties: IIssueDisplayProperties;
@ -35,8 +36,10 @@ export const SpreadsheetTable = observer((props: Props) => {
canEditProperties, canEditProperties,
} = props; } = props;
const handleKeyBoardNavigation = useTableKeyboardNavigation();
return ( return (
<table className="overflow-y-auto"> <table className="overflow-y-auto" onKeyDown={handleKeyBoardNavigation}>
<SpreadsheetHeader <SpreadsheetHeader
displayProperties={displayProperties} displayProperties={displayProperties}
displayFilters={displayFilters} displayFilters={displayFilters}

View File

@ -28,6 +28,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
icon: FC<ISvgIcons>; icon: FC<ISvgIcons>;
Column: React.FC<{ Column: React.FC<{
issue: TIssue; issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void; onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean; disabled: boolean;
}>; }>;

View File

@ -1,23 +1,31 @@
import { useCallback } from "react"; import { useCallback } from "react";
type TUseDropdownKeyDown = { type TUseDropdownKeyDown = {
(onEnterKeyDown: () => void, onEscKeyDown: () => void): (event: React.KeyboardEvent<HTMLElement>) => void; (onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): (
event: React.KeyboardEvent<HTMLElement>
) => void;
}; };
export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown) => { export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
const stopEventPropagation = (event: React.KeyboardEvent<HTMLElement>) => {
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
};
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => { (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.stopPropagation(); stopEventPropagation(event);
event.preventDefault();
onEnterKeyDown(); onEnterKeyDown();
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
event.stopPropagation(); stopEventPropagation(event);
event.preventDefault();
onEscKeyDown(); onEscKeyDown();
} }
}, },
[onEnterKeyDown, onEscKeyDown] [onEnterKeyDown, onEscKeyDown, stopEventPropagation]
); );
return handleKeyDown; return handleKeyDown;

View File

@ -0,0 +1,56 @@
export const useTableKeyboardNavigation = () => {
const getPreviousRow = (element: HTMLElement) => {
const previousRow = element.closest("tr")?.previousSibling;
if (previousRow) return previousRow;
//if previous row does not exist in the parent check the row with the header of the table
return element.closest("tbody")?.previousSibling?.childNodes?.[0];
};
const getNextRow = (element: HTMLElement) => {
const nextRow = element.closest("tr")?.nextSibling;
if (nextRow) return nextRow;
//if next row does not exist in the parent check the row with the body of the table
return element.closest("thead")?.nextSibling?.childNodes?.[0];
};
const handleKeyBoardNavigation = function (e: React.KeyboardEvent<HTMLTableElement>) {
const element = e.target as HTMLElement;
if (!(element?.tagName === "TD" || element?.tagName === "TH")) return;
let c: HTMLElement | null = null;
if (e.key == "ArrowRight") {
// Right Arrow
c = element.nextSibling as HTMLElement;
} else if (e.key == "ArrowLeft") {
// Left Arrow
c = element.previousSibling as HTMLElement;
} else if (e.key == "ArrowUp") {
// Up Arrow
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
const prevRow = getPreviousRow(element);
c = prevRow?.childNodes?.[index] as HTMLElement;
} else if (e.key == "ArrowDown") {
// Down Arrow
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
const nextRow = getNextRow(element);
c = nextRow?.childNodes[index] as HTMLElement;
} else if (e.key == "Enter" || e.key == "Space") {
e.preventDefault();
(element?.querySelector(".clickable") as HTMLElement)?.click();
return;
}
if (!c) return;
e.preventDefault();
c?.focus();
c?.scrollIntoView({ behavior: "smooth", block: "center", inline: "end" });
};
return handleKeyBoardNavigation;
};