From f3a9f3760722dc0a34ea1776468afc59f05dc605 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 18 Nov 2024 20:48:43 +0530 Subject: [PATCH] change: [M3-8533, M3-8761] - Fix firewall rules table and Replace `react-beautiful-dnd` with `dnd-kit` lib (#11127) * Fix firewall rules table * Update the comment * Added changeset: Broken firewall rules table * Keeping PolicyRow to 2 grids for sm breakpoint * Add padding to headers and cells * Add line height to StyledHeaderItemBox * Update some styles * Update styles * Fix broken firewall rules table and replace react-beautiful-dnd with dnd-kit * Set cursor style to 'grab' or 'grabbing' based on drag state * isDragging state var pos fix * Add TouchSensor and collisionDetection * Add width to table cells for improved layout * Some cleanup... * Update width * Fix colors for pending deletion row and clean up styling for rule btn * Added changeset: Broken firewall rules table * Added changeset: Replace `react-beautiful-dnd` with `dnd-kit` library * Remove react-beautiful-dnd from the codebase * Remove react-beautiful-dnd from the codebase * Remove initial pos focus on drag end and enable rows to drag over others * Refactoring... * Update firewall e2e tests * Adjust label column to prevent text wrapping on mobile and some clean up... * Improve smoothness of row dragging * overflow logic & styling * add missing keyboard sensor * Fix weird scalling with varying row heights and enable drag on navs as well * Remove Label col nowrap to keep table inplace on overflow and some adjustments * revert overflow issue * revert overflow issue * fix sortable via keyboard behavior * Clean up imports * Fix scrolling behavior when using keyboard keys * Add changeset and fix typo * Fix focus reset when dragging elements via keyboard * Add theme compatible background color focus --------- Co-authored-by: Alban Bailly --- .../pr-11127-changed-1729515938754.md | 5 + .../pr-11127-fixed-1729515738287.md | 5 + .../pr-11127-fixed-1730275015638.md | 5 + .../core/firewalls/update-firewall.spec.ts | 4 +- packages/manager/package.json | 4 +- .../src/components/Table/Table.styles.ts | 32 +- .../Rules/FirewallRuleTable.styles.ts | 75 +--- .../Rules/FirewallRuleTable.test.tsx | 3 +- .../Rules/FirewallRuleTable.tsx | 404 ++++++++++-------- .../src/utilities/CustomKeyboardSensor.ts | 250 +++++++++++ yarn.lock | 81 ++-- 11 files changed, 569 insertions(+), 299 deletions(-) create mode 100644 packages/manager/.changeset/pr-11127-changed-1729515938754.md create mode 100644 packages/manager/.changeset/pr-11127-fixed-1729515738287.md create mode 100644 packages/manager/.changeset/pr-11127-fixed-1730275015638.md create mode 100644 packages/manager/src/utilities/CustomKeyboardSensor.ts diff --git a/packages/manager/.changeset/pr-11127-changed-1729515938754.md b/packages/manager/.changeset/pr-11127-changed-1729515938754.md new file mode 100644 index 00000000000..d6dfd493df3 --- /dev/null +++ b/packages/manager/.changeset/pr-11127-changed-1729515938754.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Replace `react-beautiful-dnd` with `dnd-kit` library ([#11127](https://github.com/linode/manager/pull/11127)) diff --git a/packages/manager/.changeset/pr-11127-fixed-1729515738287.md b/packages/manager/.changeset/pr-11127-fixed-1729515738287.md new file mode 100644 index 00000000000..67f046ac55c --- /dev/null +++ b/packages/manager/.changeset/pr-11127-fixed-1729515738287.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Broken firewall rules table ([#11127](https://github.com/linode/manager/pull/11127)) diff --git a/packages/manager/.changeset/pr-11127-fixed-1730275015638.md b/packages/manager/.changeset/pr-11127-fixed-1730275015638.md new file mode 100644 index 00000000000..86a2606bf0b --- /dev/null +++ b/packages/manager/.changeset/pr-11127-fixed-1730275015638.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Table component styling issue for `noOverflow` property ([#11127](https://github.com/linode/manager/pull/11127)) diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 7269f369631..a76d5ee8a09 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -222,7 +222,7 @@ describe('update firewall', () => { // Confirm that the inbound rules are listed on edit page with expected configuration cy.findByText(inboundRule.label!) .should('be.visible') - .closest('li') + .closest('tr') .within(() => { cy.findByText(inboundRule.protocol).should('be.visible'); cy.findByText(inboundRule.ports!).should('be.visible'); @@ -237,7 +237,7 @@ describe('update firewall', () => { // Confirm that the outbound rules are listed on edit page with expected configuration cy.findByText(outboundRule.label!) .should('be.visible') - .closest('li') + .closest('tr') .within(() => { cy.findByText(outboundRule.protocol).should('be.visible'); cy.findByText(outboundRule.ports!).should('be.visible'); diff --git a/packages/manager/package.json b/packages/manager/package.json index 6d199b422df..0a5c223b0b8 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -14,6 +14,9 @@ "url": "https://github.com/Linode/manager.git" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", @@ -61,7 +64,6 @@ "qrcode.react": "^0.8.0", "ramda": "~0.25.0", "react": "^18.2.0", - "react-beautiful-dnd": "^13.0.0", "react-csv": "^2.0.3", "react-dom": "^18.2.0", "react-dropzone": "~11.2.0", diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index d0a07122ea4..a1a07d39650 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -13,25 +13,25 @@ export const StyledTableWrapper = styled('div', { 'spacingTop', ]), })(({ theme, ...props }) => ({ + '& thead': { + '& th': { + '&:first-of-type': { + borderLeft: 'none', + }, + '&:last-of-type': { + borderRight: 'none', + }, + backgroundColor: theme.bg.tableHeader, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, + borderTop: `1px solid ${theme.borderColors.borderTable}`, + fontFamily: theme.font.bold, + padding: '10px 15px', + }, + }, marginBottom: props.spacingBottom !== undefined ? props.spacingBottom : 0, marginTop: props.spacingTop !== undefined ? props.spacingTop : 0, ...(!props.noOverflow && { - '& thead': { - '& th': { - '&:first-of-type': { - borderLeft: 'none', - }, - '&:last-of-type': { - borderRight: 'none', - }, - backgroundColor: theme.bg.tableHeader, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `1px solid ${theme.borderColors.borderTable}`, - fontFamily: theme.font.bold, - padding: '10px 15px', - }, - }, overflowX: 'auto', overflowY: 'hidden', }), diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts index 7047efed1b9..13dd1fec422 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts @@ -7,47 +7,29 @@ import type { FirewallRuleTableRowProps } from './FirewallRuleTable'; type StyledFirewallRuleButtonProps = Pick; -interface FirewallRuleTableRowPropsWithRuleId +interface FirewallRuleTableRowPropsWithRuleIndex extends Pick { - ruleId: number; + ruleIndex: number; } -interface StyledFirewallRuleBoxProps - extends FirewallRuleTableRowPropsWithRuleId { +interface StyledFirewallRuleTableRowProps + extends FirewallRuleTableRowPropsWithRuleIndex { status: FirewallRuleTableRowProps['status']; } -export const sxBox = { - alignItems: 'center', - display: 'flex', - width: '100%', -}; - -export const sxItemSpacing = { - padding: `0 8px`, -}; - -export const StyledFirewallRuleBox = styled(Box, { - label: 'StyledFirewallRuleBox', - shouldForwardProp: omittedProps(['originalIndex', 'ruleId']), -})( - ({ disabled, originalIndex, ruleId, status, theme }) => ({ - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableStatic, - fontSize: '0.875rem', - margin: 0, - ...sxBox, - +// Note: Use 'tr' instead of 'TableRow' here for a smoother draggable user experience. +export const StyledTableRow = styled('tr', { + label: 'StyledTableRow', + shouldForwardProp: omittedProps(['originalIndex', 'ruleIndex']), +})( + ({ disabled, originalIndex, ruleIndex, status, theme }) => ({ // Conditional styles - // Highlight the row if it's been modified or reordered. ID is the current index, + // Highlight the row if it's been modified or reordered. ruleIndex is the current index, // so if it doesn't match the original index we know that the rule has been moved. ...(status === 'PENDING_DELETION' || disabled - ? { - '& td': { color: '#D2D3D4' }, - backgroundColor: 'rgba(247, 247, 247, 0.25)', - } + ? { backgroundColor: theme.color.grey7 } : {}), - ...(status === 'MODIFIED' || status === 'NEW' || originalIndex !== ruleId + ...(status === 'MODIFIED' || status === 'NEW' || originalIndex !== ruleIndex ? { backgroundColor: theme.bg.lightBlue1 } : {}), ...(status === 'NOT_MODIFIED' ? { backgroundColor: theme.bg.bgPaper } : {}), @@ -57,38 +39,17 @@ export const StyledFirewallRuleBox = styled(Box, { export const StyledInnerBox = styled(Box, { label: 'StyledInnerBox' })( ({ theme }) => ({ backgroundColor: theme.bg.tableHeader, - color: theme.textColors.tableHeader, fontFamily: theme.font.bold, fontSize: '.875rem', - height: '46px', - }) -); - -export const StyledUlBox = styled(Box, { label: 'StyledUlBox' })( - ({ theme }) => ({ - alignItems: 'center', - backgroundColor: theme.bg.bgPaper, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableStatic, - display: 'flex', - fontSize: '0.875rem', - justifyContent: 'center', - padding: theme.spacing(1), - width: '100%', }) ); export const StyledFirewallRuleButton = styled('button', { label: 'StyledFirewallRuleButton', -})(({ status, theme }) => ({ +})(() => ({ backgroundColor: 'transparent', border: 'none', cursor: 'pointer', - - // Conditional styles - ...(status !== 'PENDING_DELETION' - ? { backgroundColor: theme.bg.lightBlue1 } - : {}), })); export const StyledFirewallTableButton = styled(Button, { @@ -137,11 +98,3 @@ export const StyledDragIndicator = styled(DragIndicator, { position: 'relative', top: 2, })); - -export const StyledUl = styled('ul', { label: 'StyledUl' })(({ theme }) => ({ - backgroundColor: theme.color.border3, - listStyle: 'none', - margin: 0, - paddingLeft: 0, - width: '100%', -})); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.test.tsx index 4d5e2d27442..520821d4ed8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.test.tsx @@ -1,5 +1,6 @@ import { firewallRuleToRowData } from './FirewallRuleTable'; -import { ExtendedFirewallRule } from './firewallRuleEditor'; + +import type { ExtendedFirewallRule } from './firewallRuleEditor'; describe('Firewall rule table tests', () => { describe('firewallRuleToRowData', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index b9f8229d100..ba46ed1fb97 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -1,14 +1,35 @@ +import { + DndContext, + MouseSensor, + PointerSensor, + TouchSensor, + closestCorners, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { prop, uniqBy } from 'ramda'; import * as React from 'react'; -import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import Undo from 'src/assets/icons/undo.svg'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Hidden } from 'src/components/Hidden'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { Typography } from 'src/components/Typography'; import { generateAddressesLabel, @@ -16,6 +37,7 @@ import { predefinedFirewallFromRule as ruleToPredefinedFirewall, } from 'src/features/Firewalls/shared'; import { capitalize } from 'src/utilities/capitalize'; +import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; import { @@ -23,24 +45,18 @@ import { StyledButtonDiv, StyledDragIndicator, StyledErrorDiv, - StyledFirewallRuleBox, StyledFirewallRuleButton, StyledFirewallTableButton, StyledHeaderDiv, - StyledInnerBox, - StyledUl, - StyledUlBox, - sxBox, - sxItemSpacing, + StyledTableRow, } from './FirewallRuleTable.styles'; import { sortPortString } from './shared'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; import type { ExtendedFirewallRule, RuleStatus } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; +import type { DragEndEvent } from '@dnd-kit/core'; import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; -import type { Theme } from '@mui/material/styles'; -import type { DropResult } from 'react-beautiful-dnd'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; interface RuleRow { @@ -49,6 +65,7 @@ interface RuleRow { description?: null | string; errors?: FirewallRuleError[]; id: number; + index: number; label?: null | string; originalIndex: number; ports: string; @@ -96,8 +113,10 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { triggerUndo, } = props; - const theme: Theme = useTheme(); - const xsDown = useMediaQuery(theme.breakpoints.down('sm')); + const theme = useTheme(); + const smDown = useMediaQuery(theme.breakpoints.down('sm')); + const mdDown = useMediaQuery(theme.breakpoints.down('md')); + const lgDown = useMediaQuery(theme.breakpoints.down('lg')); const addressColumnLabel = category === 'inbound' ? 'sources' : 'destinations'; @@ -113,9 +132,22 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const screenReaderMessage = 'Some screen readers may require you to enter focus mode to interact with firewall rule list items. In focus mode, press spacebar to begin a drag or tab to access item actions.'; - const onDragEnd = (result: DropResult) => { - if (result.destination) { - triggerReorder(result.source.index, result.destination?.index); + const getRowDataIndex = React.useMemo(() => { + return (id: number) => rowData.findIndex((data) => data.id === id); + }, [rowData]); + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active && over && active.id !== over.id) { + const sourceIndex = getRowDataIndex(Number(active.id)); + const destinationIndex = getRowDataIndex(Number(over.id)); + triggerReorder(sourceIndex, destinationIndex); + } + + // Remove focus from the initial position when the drag ends. + if (document.activeElement) { + (document.activeElement as HTMLElement).blur(); } }; @@ -123,6 +155,23 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handlePolicyChange(category, newPolicy); }; + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 4, + }, + }), + useSensor(MouseSensor, { + activationConstraint: { + distance: 4, + }, + }), + useSensor(TouchSensor), + useSensor(CustomKeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + return ( <> @@ -139,99 +188,82 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { aria-label={`${category} Rules List`} sx={{ margin: 0, width: '100%' }} > - - - Label - - - Protocol - - - - Port Range - - - {capitalize(addressColumnLabel)} - - - Action - - - - - {(provided) => ( - - {rowData.length === 0 ? ( - - {zeroRulesMessage} - - ) : ( - rowData.map((thisRuleRow: RuleRow, index) => ( - - {(provided) => ( -
  • - -
  • - )} -
    - )) - )} - {provided.placeholder} -
    + + + + + Label + + + Protocol + + + Port Range + + {capitalize(addressColumnLabel)} + + + Action + + + + + {rowData.length === 0 ? ( + + ) : ( + + {rowData.map((thisRuleRow: RuleRow) => ( + + ))} + )} - - - - + +
    + +
    ); @@ -254,15 +286,13 @@ export interface FirewallRuleTableRowProps extends RuleRow { } const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { - const theme: Theme = useTheme(); - const xsDown = useMediaQuery(theme.breakpoints.down('sm')); - const { action, addresses, disabled, errors, id, + index, label, originalIndex, ports, @@ -276,89 +306,109 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, - idx: id, + idx: index, triggerCloneFirewallRule, triggerDeleteFirewallRule, triggerOpenRuleDrawerForEditing, }; + const theme = useTheme(); + + const { + active, + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }); + + const isActive = Boolean(active); + + // dnd-kit styles + const rowStyles = { + '& td': { + // Highly recommend to set the `touch-action: none` for all the draggable elements- + // in order to prevent scrolling on mobile devices. + // refer to https://docs.dndkit.com/api-documentation/sensors/pointer#touch-action + touchAction: 'none', + }, + ':focus': { + backgroundColor: isActive + ? theme.tokens.background.Neutralsubtle + : theme.tokens.background.Normal, + }, + cursor: isActive ? 'grabbing' : 'grab', + position: 'relative', + transform: CSS.Translate.toString(transform), + transition: isActive ? transition : 'none', + zIndex: isDragging ? 9999 : 0, + } as const; + return ( - - + {label || ( triggerOpenRuleDrawerForEditing(id)} + onClick={() => triggerOpenRuleDrawerForEditing(index)} > Add a label - )}{' '} - + )} + - + {protocol} - + - + {ports === '1-65535' ? 'All Ports' : ports} - - + + - + - + {capitalize(action?.toLocaleLowerCase() ?? '')} - - - {status !== 'NOT_MODIFIED' ? ( - - triggerUndo(id)} - status={status} - > - - + + + + {status !== 'NOT_MODIFIED' ? ( + + triggerUndo(index)} + status={status} + > + + + + + ) : ( - - ) : ( - - )} - - + )} + + + ); }); @@ -377,9 +427,9 @@ const policyOptions: FirewallOptionItem[] = [ export const PolicyRow = React.memo((props: PolicyRowProps) => { const { category, disabled, handlePolicyChange, policy } = props; const theme = useTheme(); - const mdDown = useMediaQuery(theme.breakpoints.down('lg')); + const lgDown = useMediaQuery(theme.breakpoints.down('lg')); - const helperText = mdDown ? ( + const helperText = lgDown ? ( {capitalize(category)} policy: ) : ( @@ -389,35 +439,41 @@ export const PolicyRow = React.memo((props: PolicyRowProps) => { ); // Using a grid here to keep the Select and the helper text aligned - // with the Action column. + // with the Action column for screens < 'md', and with the last column for screens >= 'md'. const sxBoxGrid = { alignItems: 'center', backgroundColor: theme.bg.bgPaper, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, + border: `1px solid ${theme.borderColors.borderTable}`, color: theme.textColors.tableStatic, display: 'grid', fontSize: '.875rem', - gridTemplateAreas: `'one two three four five'`, - gridTemplateColumns: '32% 10% 10% 15% 120px', + gridTemplateAreas: `'one two three four five six'`, + gridTemplateColumns: '26% 10% 15% 15% 10% 120px', height: '40px', marginTop: '10px', [theme.breakpoints.down('lg')]: { + gridTemplateAreas: `'one two three four five'`, + gridTemplateColumns: '32% 15% 15% 10% 120px', + }, + [theme.breakpoints.down('md')]: { gridTemplateAreas: `'one two three four'`, - gridTemplateColumns: '32% 15% 15% 120px', + gridTemplateColumns: '50% 15% 15% 20%', }, [theme.breakpoints.down('sm')]: { gridTemplateAreas: `'one two'`, - gridTemplateColumns: '50% 50%', + gridTemplateColumns: '65% 35%', }, width: '100%', }; const sxBoxPolicyText = { - gridArea: '1 / 1 / 1 / 5', + gridArea: '1 / 1 / 1 / 6', padding: '0px 15px 0px 15px', - textAlign: 'right', [theme.breakpoints.down('lg')]: { + gridArea: '1 / 1 / 1 / 5', + }, + [theme.breakpoints.down('md')]: { gridArea: '1 / 1 / 1 / 4', }, [theme.breakpoints.down('sm')]: { @@ -426,8 +482,11 @@ export const PolicyRow = React.memo((props: PolicyRowProps) => { }; const sxBoxPolicySelect = { - gridArea: 'five', + gridArea: 'six', [theme.breakpoints.down('lg')]: { + gridArea: 'five', + }, + [theme.breakpoints.down('md')]: { gridArea: 'four', }, [theme.breakpoints.down('sm')]: { @@ -503,7 +562,8 @@ export const firewallRuleToRowData = ( return { ...thisRule, addresses: generateAddressesLabel(thisRule.addresses), - id: idx, + id: idx + 1, // ids are 1-indexed, as id given to the useSortable hook cannot be 0 + index: idx, ports: sortPortString(thisRule.ports || ''), type: generateRuleLabel(ruleType), }; diff --git a/packages/manager/src/utilities/CustomKeyboardSensor.ts b/packages/manager/src/utilities/CustomKeyboardSensor.ts new file mode 100644 index 00000000000..e32e46ff1e7 --- /dev/null +++ b/packages/manager/src/utilities/CustomKeyboardSensor.ts @@ -0,0 +1,250 @@ +// Customizing KeyboardSensor from dnd-kit to meet our requirements. +// - Prevent scrolling while using keyboard keys. + +import { KeyboardCode, defaultCoordinates } from '@dnd-kit/core'; +import { + add as getAdjustedCoordinates, + subtract as getCoordinatesDelta, + getOwnerDocument, + getWindow, + isKeyboardEvent, +} from '@dnd-kit/utilities'; + +import type { + Activators, + KeyboardCodes, + KeyboardCoordinateGetter, + KeyboardSensorOptions, + KeyboardSensorProps, + SensorInstance, +} from '@dnd-kit/core'; +import type { Coordinates } from '@dnd-kit/utilities'; + +class Listeners { + private listeners: [ + string, + EventListenerOrEventListenerObject, + AddEventListenerOptions | boolean | undefined + ][] = []; + + public removeAll = () => { + this.listeners.forEach((listener) => + this.target?.removeEventListener(...listener) + ); + }; + + constructor(private target: EventTarget | null) {} + + public add( + eventName: string, + handler: (event: T) => void, + options?: AddEventListenerOptions | boolean + ) { + // eslint-disable-next-line scanjs-rules/call_addEventListener + this.target?.addEventListener(eventName, handler as EventListener, options); + this.listeners.push([eventName, handler as EventListener, options]); + } +} + +const defaultKeyboardCodes: KeyboardCodes = { + cancel: [KeyboardCode.Esc], + end: [KeyboardCode.Space, KeyboardCode.Enter], + start: [KeyboardCode.Space, KeyboardCode.Enter], +}; + +const defaultKeyboardCoordinateGetter: KeyboardCoordinateGetter = ( + event, + { currentCoordinates } +) => { + switch (event.code) { + case KeyboardCode.Right: + return { + ...currentCoordinates, + x: currentCoordinates.x + 25, + }; + case KeyboardCode.Left: + return { + ...currentCoordinates, + x: currentCoordinates.x - 25, + }; + case KeyboardCode.Down: + return { + ...currentCoordinates, + y: currentCoordinates.y + 25, + }; + case KeyboardCode.Up: + return { + ...currentCoordinates, + y: currentCoordinates.y - 25, + }; + } + + return undefined; +}; + +enum EventName { + Click = 'click', + ContextMenu = 'contextmenu', + DragStart = 'dragstart', + Keydown = 'keydown', + Resize = 'resize', + SelectionChange = 'selectionchange', + VisibilityChange = 'visibilitychange', +} + +export class CustomKeyboardSensor implements SensorInstance { + static activators: Activators = [ + { + eventName: 'onKeyDown' as const, + handler: ( + event: React.KeyboardEvent, + { keyboardCodes = defaultKeyboardCodes, onActivation }, + { active } + ) => { + const { code } = event.nativeEvent; + + if (keyboardCodes.start.includes(code)) { + const activator = active.activatorNode.current; + + if (activator && event.target !== activator) { + return false; + } + + event.preventDefault(); + onActivation?.({ event: event.nativeEvent }); + + return true; + } + + return false; + }, + }, + ]; + private listeners: Listeners; + private referenceCoordinates: Coordinates | undefined; + private windowListeners: Listeners; + + public autoScrollEnabled = false; + + constructor(private props: KeyboardSensorProps) { + const { + event: { target }, + } = props; + + this.props = props; + this.listeners = new Listeners(getOwnerDocument(target)); + this.windowListeners = new Listeners(getWindow(target)); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleCancel = this.handleCancel.bind(this); + + this.attach(); + } + + private attach() { + this.handleStart(); + + this.windowListeners.add(EventName.Resize, this.handleCancel); + this.windowListeners.add(EventName.VisibilityChange, this.handleCancel); + + // Add focus style when draggable element is dragging. + const activator = this.props.activeNode.node.current; + if (activator) { + activator.style.outline = '1px dashed grey'; + } + + setTimeout(() => { + this.listeners.add(EventName.Keydown, this.handleKeyDown); + }); + } + + private detach() { + this.listeners.removeAll(); + this.windowListeners.removeAll(); + + // Clear focus style when draggable element is dropped + const dropTarget = this.props.activeNode.node.current; + if (dropTarget) { + dropTarget.style.outline = 'none'; + } + } + + private handleCancel(event: Event) { + const { onCancel } = this.props; + + event.preventDefault(); + this.detach(); + onCancel(); + } + + private handleEnd(event: Event) { + const { onEnd } = this.props; + + event.preventDefault(); + this.detach(); + onEnd(); + } + + private handleKeyDown(event: Event) { + if (isKeyboardEvent(event)) { + const { active, context, options } = this.props; + const { + coordinateGetter = defaultKeyboardCoordinateGetter, + keyboardCodes = defaultKeyboardCodes, + } = options; + const { code } = event; + + if (keyboardCodes.end.includes(code)) { + this.handleEnd(event); + return; + } + + if (keyboardCodes.cancel.includes(code)) { + this.handleCancel(event); + return; + } + + const { collisionRect } = context.current; + const currentCoordinates = collisionRect + ? { x: collisionRect.left, y: collisionRect.top } + : defaultCoordinates; + + if (!this.referenceCoordinates) { + this.referenceCoordinates = currentCoordinates; + } + + const newCoordinates = coordinateGetter(event, { + active, + context: context.current, + currentCoordinates, + }); + + if (newCoordinates) { + const scrollDelta = { + x: 0, + y: 0, + }; + + this.handleMove( + event, + getAdjustedCoordinates( + getCoordinatesDelta(newCoordinates, this.referenceCoordinates), + scrollDelta + ) + ); + } + } + } + + private handleMove(event: Event, coordinates: Coordinates) { + const { onMove } = this.props; + + event.preventDefault(); + onMove(coordinates); + } + + private handleStart() { + const { onStart } = this.props; + + onStart(defaultCoordinates); + } +} diff --git a/yarn.lock b/yarn.lock index 17680d9e989..d27dbbb2c2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,6 +426,37 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@dnd-kit/accessibility@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz#1054e19be276b5f1154ced7947fc0cb5d99192e0" + integrity sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.1.0.tgz#e81a3d10d9eca5d3b01cbf054171273a3fe01def" + integrity sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg== + dependencies: + "@dnd-kit/accessibility" "^3.1.0" + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/sortable@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-8.0.0.tgz#086b7ac6723d4618a4ccb6f0227406d8a8862a96" + integrity sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g== + dependencies: + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b" + integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.12.0": version "11.12.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz#7b43debb250c313101b3f885eba634f1d723fcc2" @@ -2438,7 +2469,7 @@ dependencies: "@types/react" "*" -"@types/react-redux@^7.1.20", "@types/react-redux@~7.1.7": +"@types/react-redux@~7.1.7": version "7.1.34" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.34.tgz#83613e1957c481521e6776beeac4fd506d11bd0e" integrity sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ== @@ -3993,13 +4024,6 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== -css-box-model@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" - integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== - dependencies: - tiny-invariant "^1.0.6" - css-line-break@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" @@ -7208,7 +7232,7 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -memoize-one@^5.0.0, memoize-one@^5.1.1: +memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -8276,11 +8300,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -raf-schd@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" - integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== - raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -8308,19 +8327,6 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" -react-beautiful-dnd@^13.0.0: - version "13.1.1" - resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" - integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== - dependencies: - "@babel/runtime" "^7.9.2" - css-box-model "^1.2.0" - memoize-one "^5.1.1" - raf-schd "^4.0.2" - react-redux "^7.2.0" - redux "^4.0.4" - use-memo-one "^1.1.1" - react-colorful@^5.1.2: version "5.6.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" @@ -8405,7 +8411,7 @@ react-is@^16.10.2, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react- resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1, react-is@^17.0.2: +react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== @@ -8427,18 +8433,6 @@ react-number-format@^3.5.0: dependencies: prop-types "^15.6.0" -react-redux@^7.2.0: - version "7.2.9" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" - integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== - dependencies: - "@babel/runtime" "^7.15.4" - "@types/react-redux" "^7.1.20" - hoist-non-react-statics "^3.3.2" - loose-envify "^1.4.0" - prop-types "^15.7.2" - react-is "^17.0.2" - react-redux@~7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.3.tgz#717a3d7bbe3a1b2d535c94885ce04cdc5a33fc79" @@ -9590,7 +9584,7 @@ through@^2.3.6, through@^2.3.8, through@~2.3.4: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.3.1, tiny-invariant@^1.3.3: +tiny-invariant@^1.0.2, tiny-invariant@^1.3.1, tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== @@ -10041,11 +10035,6 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -use-memo-one@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" - integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== - use-sync-external-store@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"