From fb3dd77b66bed774cb4bbf58bd9f7502c8faffeb Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 8 Feb 2024 11:49:00 +0530 Subject: [PATCH] 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 --- packages/ui/src/dropdowns/custom-menu.tsx | 36 +++++++--- packages/ui/src/dropdowns/helper.tsx | 2 + .../ui/src/hooks/use-dropdown-key-down.tsx | 13 +++- web/components/common/breadcrumb-link.tsx | 2 +- web/components/dropdowns/cycle.tsx | 8 ++- web/components/dropdowns/date.tsx | 8 ++- web/components/dropdowns/estimate.tsx | 10 ++- .../dropdowns/member/project-member.tsx | 10 ++- web/components/dropdowns/member/types.d.ts | 1 + .../dropdowns/member/workspace-member.tsx | 9 ++- web/components/dropdowns/module.tsx | 10 ++- web/components/dropdowns/priority.tsx | 10 ++- web/components/dropdowns/project.tsx | 10 ++- web/components/dropdowns/state.tsx | 10 ++- .../issue-layouts/properties/labels.tsx | 20 ++++-- .../spreadsheet/columns/assignee-column.tsx | 4 +- .../spreadsheet/columns/due-date-column.tsx | 4 +- .../spreadsheet/columns/estimate-column.tsx | 4 +- .../spreadsheet/columns/header-column.tsx | 9 ++- .../spreadsheet/columns/label-column.tsx | 6 +- .../spreadsheet/columns/priority-column.tsx | 6 +- .../spreadsheet/columns/start-date-column.tsx | 4 +- .../spreadsheet/columns/state-column.tsx | 4 +- .../spreadsheet/issue-column.tsx | 68 +++++++++++++++++++ .../issue-layouts/spreadsheet/issue-row.tsx | 60 ++++++---------- .../spreadsheet/spreadsheet-header-column.tsx | 46 +++++++++++++ .../spreadsheet/spreadsheet-header.tsx | 36 ++++------ .../spreadsheet/spreadsheet-table.tsx | 5 +- web/constants/spreadsheet.ts | 1 + web/hooks/use-dropdown-key-down.tsx | 22 ++++-- web/hooks/use-table-keyboard-navigation.tsx | 56 +++++++++++++++ 31 files changed, 368 insertions(+), 126 deletions(-) create mode 100644 web/components/issues/issue-layouts/spreadsheet/issue-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx create mode 100644 web/hooks/use-table-keyboard-navigation.tsx diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 7ef99370f..c7cce2475 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -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 = ( - { - if (closeOnSelect) closeDropdown(); - }} - static - > +
{ 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} @@ -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} > @@ -142,6 +156,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); }} + tabIndex={customButtonTabIndex} > {label} {!noChevron && } @@ -159,6 +174,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const MenuItem: React.FC = (props) => { const { children, onClick, className = "" } = props; + return ( {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 06f1c44c0..930f332b9 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -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; } diff --git a/packages/ui/src/hooks/use-dropdown-key-down.tsx b/packages/ui/src/hooks/use-dropdown-key-down.tsx index 1bb861477..b93a4d551 100644 --- a/packages/ui/src/hooks/use-dropdown-key-down.tsx +++ b/packages/ui/src/hooks/use-dropdown-key-down.tsx @@ -1,16 +1,23 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent) => void; + ( + onOpen: () => void, + onClose: () => void, + isOpen: boolean, + selectActiveItem?: () => void + ): (event: React.KeyboardEvent) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => { const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") { - event.stopPropagation(); if (!isOpen) { + event.stopPropagation(); onOpen(); + } else { + selectActiveItem && selectActiveItem(); } } else if (event.key === "Escape" && isOpen) { event.stopPropagation(); diff --git a/web/components/common/breadcrumb-link.tsx b/web/components/common/breadcrumb-link.tsx index aebd7fc02..e5f1dbce6 100644 --- a/web/components/common/breadcrumb-link.tsx +++ b/web/components/common/breadcrumb-link.tsx @@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC = (props) => { const { href, label, icon } = props; return ( -
  • +
  • {href ? ( void; + onClose?: () => void; projectId: string; value: string | null; }; @@ -47,6 +48,7 @@ export const CycleDropdown: React.FC = observer((props) => { dropdownArrowClassName = "", hideIcon = false, onChange, + onClose, placeholder = "Cycle", placement, projectId, @@ -123,8 +125,10 @@ export const CycleDropdown: React.FC = 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 = observer((props) => {
    @@ -216,10 +226,10 @@ export const IssuePropertyLabels: React.FC = observer((pro + 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 }) => ( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index e63a94b8c..b9450141b 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props } buttonClassName="text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 775275ca4..c5674cee9 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -36,6 +37,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 0c86b24c0..f7a472b49 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -6,12 +6,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC = observer((props: Props buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx index dc9f8c7c6..73478c6ac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -20,10 +20,11 @@ interface Props { property: keyof IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => 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 ( @@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
  • } + onMenuClose={onClose} placement="bottom-end" > handleOrderBy(propertyDetails.ascendingOrderKey, property)}> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index 2812fb1ec..60e429c9f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetLabelColumn: React.FC = 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 = 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} /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 1961b8717..b8801559c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -7,22 +7,24 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial,updates:any) => void; disabled: boolean; }; export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    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} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 076464f27..fcbd817b6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -36,6 +37,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 83a7c8d0f..1a029db12 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStateColumn: React.FC = observer((props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC = observer((props) => { buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx new file mode 100644 index 000000000..5d2e62fa5 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx @@ -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; + isEstimateEnabled: boolean; +}; + +export const IssueColumn = observer((props: Props) => { + const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props; + // router + const router = useRouter(); + const tableCellRef = useRef(null); + const { captureIssueEvent } = useEventTracker(); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + return ( + + + , 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(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 40ee85df7..2a97045fe 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -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(false); @@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => { {/* first column/ issue name and key column */}
    { 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" >
    -
    +
    {issueDetail.name}
    @@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => { {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; - - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - , 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} - /> - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} {isExpanded && diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx new file mode 100644 index 000000000..588c7be9e --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx @@ -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) => void; +} +export const SpreadsheetHeaderColumn = observer((props: Props) => { + const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props; + + //hooks + const tableHeaderCellRef = useRef(null); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + { + tableHeaderCellRef?.current?.focus(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 704c9f904..64d1ec0e1 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -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 ( - + #ID @@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => { - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} ); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 369e6633c..e63b01dfb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -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 ( - +
    ; Column: React.FC<{ issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }>; diff --git a/web/hooks/use-dropdown-key-down.tsx b/web/hooks/use-dropdown-key-down.tsx index 99511b0fc..228e35575 100644 --- a/web/hooks/use-dropdown-key-down.tsx +++ b/web/hooks/use-dropdown-key-down.tsx @@ -1,23 +1,31 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onEnterKeyDown: () => void, onEscKeyDown: () => void): (event: React.KeyboardEvent) => void; + (onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): ( + event: React.KeyboardEvent + ) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }; + const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { 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; diff --git a/web/hooks/use-table-keyboard-navigation.tsx b/web/hooks/use-table-keyboard-navigation.tsx new file mode 100644 index 000000000..0d1c26f3c --- /dev/null +++ b/web/hooks/use-table-keyboard-navigation.tsx @@ -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) { + 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; +};