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

View File

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

View File

@ -1,16 +1,23 @@
import { useCallback } from "react";
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(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) {
event.stopPropagation();
onOpen();
} else {
selectActiveItem && selectActiveItem();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();

View File

@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
return (
<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">
{href ? (
<Link

View File

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

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
minDate?: Date;
maxDate?: Date;
onChange: (val: Date | null) => void;
onClose?: () => void;
value: Date | string | null;
closeOnSelect?: boolean;
};
@ -42,6 +43,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
minDate,
maxDate,
onChange,
onClose,
placeholder = "Date",
placement,
showTooltip = false,
@ -74,8 +76,10 @@ export const DateDropdown: React.FC<Props> = (props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -112,7 +116,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: number | null) => void;
onClose?: () => void;
projectId: string;
value: number | null;
};
@ -46,6 +47,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "",
hideIcon = false,
onChange,
onClose,
placeholder = "Estimate",
placement,
projectId,
@ -112,8 +114,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -152,7 +156,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -162,7 +166,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -21,6 +21,7 @@ import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
type Props = {
projectId: string;
onClose?: () => void;
} & MemberDropdownProps;
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
@ -36,6 +37,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Members",
placement,
projectId,
@ -105,8 +107,10 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -144,7 +148,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -154,7 +158,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

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

View File

@ -32,6 +32,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Members",
placement,
showTooltip = false,
@ -95,8 +96,10 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -134,7 +137,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
<button
ref={setReferenceElement}
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}
>
{button}
@ -144,7 +147,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -24,6 +24,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string;
projectId: string;
showCount?: boolean;
onClose?: () => void;
} & (
| {
multiple: false;
@ -151,6 +152,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Module",
placement,
projectId,
@ -226,8 +228,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -271,7 +275,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -281,7 +285,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string;
highlightUrgent?: boolean;
onChange: (val: TIssuePriorities) => void;
onClose?: () => void;
value: TIssuePriorities;
};
@ -260,6 +261,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
hideIcon = false,
highlightUrgent = true,
onChange,
onClose,
placement,
showTooltip = false,
tabIndex,
@ -308,8 +310,10 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
@ -360,7 +364,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -370,7 +374,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: string) => void;
onClose?: () => void;
value: string | null;
};
@ -37,6 +38,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "",
hideIcon = false,
onChange,
onClose,
placeholder = "Project",
placement,
showTooltip = false,
@ -97,7 +99,9 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
@ -137,7 +141,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -147,7 +151,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: string) => void;
onClose?: () => void;
projectId: string;
value: string;
};
@ -39,6 +40,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "",
hideIcon = false,
onChange,
onClose,
placement,
projectId,
showTooltip = false,
@ -94,7 +96,9 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
};
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
@ -134,7 +138,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
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}
>
{button}
@ -144,7 +148,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
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-pointer": !disabled,

View File

@ -4,6 +4,7 @@ import { usePopper } from "react-popper";
import { Check, ChevronDown, Search, Tags } from "lucide-react";
// hooks
import { useApplication, useLabel } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
// components
import { Combobox } from "@headlessui/react";
import { Tooltip } from "@plane/ui";
@ -25,6 +26,7 @@ export interface IIssuePropertyLabels {
maxRender?: number;
noLabelBorder?: boolean;
placeholderText?: string;
onClose?: () => void;
}
export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((props) => {
@ -33,6 +35,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value,
defaultOptions = [],
onChange,
onClose,
disabled,
hideDropdownArrow = false,
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, {
placement: placement ?? "bottom-start",
modifiers: [
@ -171,13 +180,14 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value={value}
onChange={onChange}
disabled={disabled}
onKeyDownCapture={handleKeyDown}
multiple
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
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
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
@ -205,7 +215,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
displayValue={(assigned: any) => assigned?.name || ""}
/>
</div>
<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
key={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 ${
selected ? "text-custom-text-100" : "text-custom-text-200"
}`
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (

View File

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<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"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<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"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View File

@ -6,12 +6,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<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"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View File

@ -20,10 +20,11 @@ interface Props {
property: keyof IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
onClose: () => void;
}
export const SpreadsheetHeaderColumn = (props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property } = props;
export const HeaderColumn = (props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props;
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
"spreadsheetViewSorting",
@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
return (
<CustomMenu
customButtonClassName="!w-full"
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
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">
@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
</div>
</div>
}
onMenuClose={onClose}
placement="bottom-end"
>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
// hooks
const { labelMap } = useLabel();
@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
projectId={issue.project_id ?? null}
value={issue.label_ids}
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"
buttonClassName="px-2.5 h-full"
hideDropdownArrow
maxRender={1}
disabled={disabled}
placeholderText="Select labels"
onClose={onClose}
/>
);
});

View File

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

View File

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<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"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View File

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<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"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</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
import { ChevronRight, MoreHorizontal } from "lucide-react";
// constants
import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssueColumn } from "./issue-column";
// ui
import { ControlLink, Tooltip } from "@plane/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useEventTracker, useIssueDetail, useProject } from "hooks/store";
import { useIssueDetail, useProject } from "hooks/store";
// helper
import { cn } from "helpers/common.helper";
// types
@ -51,7 +52,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
//hooks
const { getProjectById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isExpanded, setExpanded] = useState<boolean>(false);
@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
{/* first column/ issue name and key column */}
<td
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,
}
)}
tabIndex={0}
>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<div
@ -149,11 +150,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
target="_blank"
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">
<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}
</div>
</Tooltip>
@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
</ControlLink>
</td>
{/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => {
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<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">
<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}
/>
</td>
</WithDisplayPropertiesHOC>
);
})}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<IssueColumn
displayProperties={displayProperties}
issueDetail={issueDetail}
disableUserActions={disableUserActions}
property={property}
handleIssues={handleIssues}
isEstimateEnabled={isEstimateEnabled}
/>
))}
</tr>
{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";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { SpreadsheetHeaderColumn } from "./columns/header-column";
import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column";
interface Props {
displayProperties: IIssueDisplayProperties;
@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => {
return (
<thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100">
<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">
<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
@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => {
</span>
</th>
{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
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property}
/>
</th>
</WithDisplayPropertiesHOC>
);
})}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<SpreadsheetHeaderColumn
property={property}
displayProperties={displayProperties}
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
isEstimateEnabled={isEstimateEnabled}
/>
))}
</tr>
</thead>
);

View File

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

View File

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

View File

@ -1,23 +1,31 @@
import { useCallback } from "react";
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(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
event.preventDefault();
stopEventPropagation(event);
onEnterKeyDown();
} else if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
stopEventPropagation(event);
onEscKeyDown();
}
},
[onEnterKeyDown, onEscKeyDown]
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
);
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;
};