diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Actions.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Actions.png index 3d009c8956e..d86e6a7a6ff 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Actions.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Actions.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png index 708615cc615..95fa59067aa 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png index c65defa64bb..c474f891b31 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Draggable_Columns.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Draggable_Columns.png new file mode 100644 index 00000000000..9e8647b9a1a Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Draggable_Columns.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png index 8037495d02e..c4b372315be 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Virtualization.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Virtualization.png index d4cf7fc11c5..a343bf2e0cd 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Virtualization.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Virtualization.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png index 6f18325f1e4..da772208ba2 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png index 6a252e0e65a..718b2b83d00 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png index 313d1b05b95..7f3958e9262 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png index c4d0cb02417..674d7ac5c16 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png index f962c6ac2bd..87654ab9c8c 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png index 8f6272aa1fa..1c836bf3b25 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png index a1298f5183b..6e1f0b4087b 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png index 5c3a8414c7b..9dce6c9f25e 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png index f2c3edcaa56..ab5e860851e 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png index 729e1e7b9f5..677a8004a09 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png index ad18e10918c..db3b0072178 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png index 9c1250bf122..3a43d0d4cc3 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png index a623e5639bd..cc890c505a4 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png index 7d079031bde..61d955ea229 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png index a665d83a29f..5b297147b42 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png index a5e204c13de..4adb9920ad2 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png index d2ee5b5cf93..2f8bde9eb07 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Actions.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Actions.png index 49a222cd160..808c15f4f20 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Actions.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Actions.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png index b381de221c2..60c7fdf9ad7 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Cell_Expansion_Popover.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png index 08fa249f8b8..5c98b4f8f8d 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Draggable_Columns.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Draggable_Columns.png new file mode 100644 index 00000000000..32af240df8d Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Draggable_Columns.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png index 4fc1dd76c87..6898c49059f 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Virtualization.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Virtualization.png index 3f3ff2ad7df..46fc5aafde7 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Virtualization.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Virtualization.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png index 88e12e3f057..b4463eea22f 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Compact.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png index ca5c3ede185..7960a220452 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Expanded.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png index b5c6edc5d97..d7a0bf532bd 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Horizontal_Lines.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png index 626db81947e..0703bbb5c9f 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Minimal.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png index 3c87f7385a4..e26c5bb1e1e 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_gridStyle_prop_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png index 4545d0ff312..b96ba5935a7 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Auto.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png index 7de3923ab07..657f2e61f7a 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png index c5fdbabe856..351cb082070 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Line_Height_Control_Column.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png index a78f4f33702..9feae4d4a31 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Custom_Row_Heights.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png index 5cdf1548b04..f0e37b80b54 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png index 7863cd55429..29c412b62da 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Line_Count_1.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png index ef691fbcec9..f0b81627601 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_rowHeightsOptions_prop_Static_Height.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png index f43b5e6d5c9..f94ac5b53b2 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Selector.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png index 708d99e621f..f91ab2e92b3 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Column_Sorting.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png index 6889ce53404..6460f4406f3 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_No_Toolbar.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png index ed45c4a6137..495ba9e3293 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Render_Custom_Toolbar.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png index 89e7edba399..34eb4c4220a 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_toolbarVisibility_prop_Toolbar_Visibility_Options.png differ diff --git a/packages/eui/changelogs/upcoming/8015.md b/packages/eui/changelogs/upcoming/8015.md new file mode 100644 index 00000000000..4fc964dc476 --- /dev/null +++ b/packages/eui/changelogs/upcoming/8015.md @@ -0,0 +1,2 @@ +- Added `columnVisibility.canDragAndDropColumns` on `EuiDataGrid` which enables reordering columns via draggable header cells + diff --git a/packages/eui/src-docs/src/views/datagrid/schema_columns/column_dragging.js b/packages/eui/src-docs/src/views/datagrid/schema_columns/column_dragging.js new file mode 100644 index 00000000000..b363d54b33c --- /dev/null +++ b/packages/eui/src-docs/src/views/datagrid/schema_columns/column_dragging.js @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import { faker } from '@faker-js/faker'; + +import { + EuiDataGrid, + EuiAvatar, + EuiToolTip, + EuiButtonIcon, +} from '../../../../../src/components'; + +const CustomHeaderCell = ({ title }) => ( + <> + {title} + + + + +); + +const columns = [ + { + id: 'avatar', + initialWidth: 40, + isResizable: false, + actions: false, + }, + { + id: 'name', + displayAsText: 'Name', + display: , + }, + { + id: 'email', + display: , + }, + { + id: 'city', + }, + { + id: 'country', + }, + { + id: 'account', + }, +]; + +const data = []; + +for (let i = 1; i < 5; i++) { + data.push({ + avatar: ( + + ), + name: `${faker.person.lastName()}, ${faker.person.firstName()} ${faker.person.suffix()}`, + email: faker.internet.email(), + city: faker.location.city(), + country: faker.location.country(), + account: faker.finance.accountNumber(), + }); +} + +export default () => { + const [visibleColumns, setVisibleColumns] = useState( + columns.map(({ id }) => id) + ); + + return ( + data[rowIndex][columnId]} + /> + ); +}; diff --git a/packages/eui/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js b/packages/eui/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js index 6119176f991..39005a78f34 100644 --- a/packages/eui/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js +++ b/packages/eui/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js @@ -17,6 +17,8 @@ import DataGridColumnWidths from './column_widths'; const dataGridColumnWidthsSource = require('!!raw-loader!./column_widths'); import DataGridColumnActions from './column_actions'; const dataGridColumnActionsSource = require('!!raw-loader!./column_actions'); +import DataGridColumnDragging from './column_dragging'; +const dataGridColumnDraggingSource = require('!!raw-loader!./column_dragging'); import DataGridFooterRow from './footer_row'; const dataGridFooterRowSource = require('!!raw-loader!./footer_row'); @@ -238,6 +240,32 @@ schemaDetectors={[ }, demo: , }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridColumnDraggingSource, + }, + ], + title: 'Draggable columns', + text: ( + +

+ To reorder columns directly instead of via the actions menu popover, + you can enable draggable EuiDataGrid header columns + via the columnVisibility.canDragAndDropColumns{' '} + prop. This will allow you to reorder the column by dragging them. +

+
+ ), + props: { + EuiDataGrid, + EuiDataGridColumn, + EuiDataGridColumnActions, + EuiListGroupItem, + }, + demo: , + }, { title: 'Control columns', source: [ diff --git a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index be3bab783be..88653842da4 100644 --- a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -628,7 +628,7 @@ exports[`EuiDataGrid rendering renders additional toolbar controls 1`] = ` > + + +
+
+
+ +
+
+ +`; diff --git a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap index 03800ef56ec..a0f12f4b3ab 100644 --- a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap +++ b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap @@ -14,7 +14,7 @@ exports[`EuiDataGridHeaderCell renders 1`] = ` tabindex="-1" >
+ + +
+ ); + + const { + panelRef, + panelProps: { onKeyDown }, + } = renderHook(usePopoverArrowNavigation).result.current; + + let mockPanel: HTMLElement; + + const preventDefault = jest.fn(); + const keyDownEvent = { preventDefault } as unknown as React.KeyboardEvent; + beforeEach(() => jest.clearAllMocks()); + + describe('early returns', () => { + it('does nothing if the up/down arrow keys are not pressed', () => { + onKeyDown({ ...keyDownEvent, key: 'Tab' }); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing if the popover contains no tabbable elements', () => { + const emptyDiv = document.createElement('div'); + panelRef(emptyDiv); + onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); + expect(preventDefault).not.toHaveBeenCalled(); + + panelRef(mockPanel); // Reset for other tests + }); + }); + + describe('when the popover panel is focused (on initial open state)', () => { + beforeEach(() => { + const { container } = render(); + + mockPanel = container.firstElementChild as HTMLElement; + panelRef(mockPanel); + + mockPanel.focus(); + }); + it('focuses the first action when the arrow down key is pressed', () => { + expect(mockPanel).toHaveFocus(); + onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); + expect(preventDefault).toHaveBeenCalled(); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'first' + ); + }); + + it('focuses the last action when the arrow up key is pressed', () => { + onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); + expect(preventDefault).toHaveBeenCalled(); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'last' + ); + }); + }); + + describe('when already focused on action buttons', () => { + describe('down arrow key', () => { + beforeEach(() => { + const { container } = render(); + + mockPanel = container.firstElementChild as HTMLElement; + panelRef(mockPanel); + }); + + it('moves focus to the the next action', () => { + (mockPanel.firstElementChild as HTMLButtonElement).focus(); + + onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'second' + ); + + onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'last' + ); + }); + + it('loops focus back to the first action when pressing down on the last action', () => { + (mockPanel.lastElementChild as HTMLButtonElement).focus(); + + onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'first' + ); + }); + }); + + describe('up arrow key', () => { + beforeEach(() => { + const { container } = render(); + + mockPanel = container.firstElementChild as HTMLElement; + panelRef(mockPanel); + }); + + it('moves focus to the previous action', () => { + (mockPanel.lastElementChild as HTMLButtonElement).focus(); + + onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'second' + ); + + onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'first' + ); + }); + + it('loops focus back to the last action when pressing up on the first action', () => { + (mockPanel.firstElementChild as HTMLButtonElement).focus(); + + onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); + expect(document.activeElement?.getAttribute('data-test-subj')).toEqual( + 'last' + ); + }); + }); + }); +}); + describe('getColumnActions', () => { const setVisibleColumns = jest.fn(); const focusFirstVisibleInteractiveCell = jest.fn(); const setIsPopoverOpen = jest.fn(); const switchColumnPos = jest.fn(); + const setIsColumnMoving = jest.fn(); const setFocusedCell = jest.fn(); const testArgs = { @@ -35,6 +262,7 @@ describe('getColumnActions', () => { setIsPopoverOpen, sorting: undefined, switchColumnPos, + setIsColumnMoving, setFocusedCell, }; @@ -195,6 +423,7 @@ describe('getColumnActions', () => { jest.runAllTimers(); expect(switchColumnPos).toHaveBeenCalledWith('B', 'A'); expect(setFocusedCell).toHaveBeenLastCalledWith([0, -1]); + expect(setIsColumnMoving).toHaveBeenCalledWith(true); callActionOnClick(moveRight); jest.runAllTimers(); diff --git a/packages/eui/src/components/datagrid/body/header/column_actions.tsx b/packages/eui/src/components/datagrid/body/header/column_actions.tsx index 400a94c80ef..c71b24ebdd2 100644 --- a/packages/eui/src/components/datagrid/body/header/column_actions.tsx +++ b/packages/eui/src/components/datagrid/body/header/column_actions.tsx @@ -6,8 +6,30 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { + useContext, + useState, + useMemo, + useCallback, + useRef, + Ref, + KeyboardEventHandler, + FunctionComponent, + memo, + useEffect, +} from 'react'; +import { tabbable, FocusableElement } from 'tabbable'; + +import { keys, useEuiMemoizedStyles } from '../../../../services'; +// Keep the i18n scope the same as EuiDataGridHeaderCell +/* eslint-disable local/i18n */ +import { EuiI18n, useEuiI18n } from '../../../i18n'; +import { EuiPopover } from '../../../popover'; +import { EuiListGroup, EuiListGroupItemProps } from '../../../list_group'; +import { EuiButtonIcon } from '../../../button'; + import { + EuiDataGridHeaderCellProps, EuiDataGridColumn, EuiDataGridColumnActions, EuiDataGridSchema, @@ -15,13 +37,271 @@ import { EuiDataGridSorting, DataGridFocusContextShape, } from '../../data_grid_types'; -import { EuiI18n } from '../../../i18n'; -import { EuiListGroupItemProps } from '../../../list_group'; +import { DataGridFocusContext } from '../../utils/focus'; import { getDetailsForSchema } from '../../utils/data_grid_schema'; import { defaultSortAscLabel, defaultSortDescLabel, } from '../../controls/column_sorting_draggable'; +import { euiDataGridHeaderCellStyles } from './data_grid_header_cell.styles'; + +export const useHasColumnActions = ( + columnActions: EuiDataGridColumn['actions'] +) => + useMemo(() => { + // By default, all column actions are enabled + if (columnActions === undefined) return true; + if (columnActions === false) return false; + if (columnActions.additional && columnActions.additional.length) + return true; + // Check if all (currently 5) default column actions have been manually disabled + const disabledActions = Object.values(columnActions).filter( + (action) => action === false + ); + return disabledActions.length < 5; + }, [columnActions]); + +// Props to pass back to EuiDataGridHeaderCell and set on EuiDataGridHeaderCellWrapper +export type PropsFromColumnActions = { + className?: string; + onKeyDown?: KeyboardEventHandler; + 'data-column-moving'?: boolean; +}; + +export const ColumnActions: FunctionComponent< + Pick< + EuiDataGridHeaderCellProps, + | 'index' + | 'column' + | 'columns' + | 'schema' + | 'schemaDetectors' + | 'setVisibleColumns' + | 'switchColumnPos' + | 'sorting' + > & { + id: string; + title: string; + hasFocusTrap: boolean; + setPropsFromColumnActions: (props: PropsFromColumnActions) => void; + actionsButtonRef: Ref; + } +> = memo( + ({ + index, + id, + title, + column, + columns, + schema, + schemaDetectors, + setVisibleColumns, + switchColumnPos, + sorting, + hasFocusTrap, + setPropsFromColumnActions, + actionsButtonRef, + }) => { + /** + * Popover logic and accessibility + */ + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => { + setIsPopoverOpen((isOpen) => !isOpen); + }, []); + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const [isActionsButtonFocused, setIsActionsButtonFocused] = useState(false); + const onFocus = useCallback(() => setIsActionsButtonFocused(true), []); + const onBlur = useCallback(() => setIsActionsButtonFocused(false), []); + + const actionsButtonAriaLabel = useEuiI18n( + 'euiDataGridHeaderCell.actionsButtonAriaLabel', + '{title}. Click to view column header actions.', + { title } + ); + const actionsEnterKeyInstructions = useEuiI18n( + 'euiDataGridHeaderCell.actionsEnterKeyInstructions', + "Press the Enter key to view this column's actions" + ); + const openActionsPopoverOnEnter: KeyboardEventHandler = useCallback((e) => { + if (e.key === keys.ENTER) { + setIsPopoverOpen(true); + } + }, []); + const popoverArrowNavigationProps = usePopoverArrowNavigation(); + + /** + * Props to set on parent EuiDataGridHeaderCell + */ + const [isColumnMoving, setIsColumnMoving] = useState(false); + + useEffect(() => { + setPropsFromColumnActions({ + className: isPopoverOpen + ? 'euiDataGridHeaderCell--isActionsPopoverOpen' + : '', + onKeyDown: openActionsPopoverOnEnter, + 'data-column-moving': isColumnMoving || undefined, + }); + }, [ + setPropsFromColumnActions, + isPopoverOpen, + openActionsPopoverOnEnter, + isColumnMoving, + ]); + + /** + * Get column actions as an array of EuiListGroup items + */ + const { setFocusedCell, focusFirstVisibleInteractiveCell } = + useContext(DataGridFocusContext); + + const columnActions = useMemo(() => { + return getColumnActions({ + column, + columns, + schema, + schemaDetectors, + setVisibleColumns, + focusFirstVisibleInteractiveCell, + sorting, + switchColumnPos, + setIsPopoverOpen, + setIsColumnMoving, + setFocusedCell, + columnFocusIndex: index, + }); + }, [ + column, + columns, + schema, + schemaDetectors, + setVisibleColumns, + focusFirstVisibleInteractiveCell, + sorting, + switchColumnPos, + setFocusedCell, + index, + ]); + + /** + * Rendering + */ + const styles = useEuiMemoizedStyles(euiDataGridHeaderCellStyles); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + {...popoverArrowNavigationProps} + > + + + ); + } +); +ColumnActions.displayName = 'EuiDataGridHeaderCellColumnActions'; + +/** + * Add keyboard arrow navigation to the cell actions popover + * to match the UX of the rest of EuiDataGrid + */ +export const usePopoverArrowNavigation = () => { + const popoverPanelRef = useRef(null); + const actionsRef = useRef(undefined); + const panelRef = useCallback((ref: HTMLElement | null) => { + popoverPanelRef.current = ref; + actionsRef.current = ref ? tabbable(ref) : undefined; + }, []); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== keys.ARROW_DOWN && e.key !== keys.ARROW_UP) return; + if (!actionsRef.current?.length) return; + + e.preventDefault(); + + const initialState = document.activeElement === popoverPanelRef.current; + const currentIndex = !initialState + ? actionsRef.current.findIndex((el) => document.activeElement === el) + : -1; + const lastIndex = actionsRef.current.length - 1; + + let indexToFocus: number; + if (initialState) { + if (e.key === keys.ARROW_DOWN) { + indexToFocus = 0; + } else if (e.key === keys.ARROW_UP) { + indexToFocus = lastIndex; + } + } else { + if (e.key === keys.ARROW_DOWN) { + indexToFocus = currentIndex + 1; + if (indexToFocus > lastIndex) { + indexToFocus = 0; + } + } else if (e.key === keys.ARROW_UP) { + indexToFocus = currentIndex - 1; + if (indexToFocus < 0) { + indexToFocus = lastIndex; + } + } + } + + actionsRef.current[indexToFocus!].focus(); + }, []); + + return { + panelRef, + panelProps: { onKeyDown }, + popoverScreenReaderText: ( + + ), + }; +}; + +/** + * Logic for returning an array of actions/items to pass to EuiListGroup + */ interface GetColumnActions { column: EuiDataGridColumn; @@ -33,6 +313,7 @@ interface GetColumnActions { setIsPopoverOpen: (value: boolean) => void; sorting: EuiDataGridSorting | undefined; switchColumnPos: (colFromId: string, colToId: string) => void; + setIsColumnMoving: (value: boolean) => void; setFocusedCell: DataGridFocusContextShape['setFocusedCell']; columnFocusIndex: number; // Index including leadingControlColumns } @@ -47,6 +328,7 @@ export const getColumnActions = ({ setIsPopoverOpen, sorting, switchColumnPos, + setIsColumnMoving, setFocusedCell, columnFocusIndex, }: GetColumnActions): EuiListGroupItemProps[] => { @@ -71,6 +353,7 @@ export const getColumnActions = ({ column, columns, switchColumnPos, + setIsColumnMoving, setFocusedCell, columnFocusIndex, }), @@ -142,6 +425,7 @@ type MoveColumnActions = Pick< | 'column' | 'columns' | 'switchColumnPos' + | 'setIsColumnMoving' | 'setFocusedCell' | 'columnFocusIndex' >; @@ -150,6 +434,7 @@ const getMoveColumnActions = ({ column, columns, switchColumnPos, + setIsColumnMoving, setFocusedCell, columnFocusIndex, }: MoveColumnActions): EuiListGroupItemProps[] => { @@ -157,6 +442,12 @@ const getMoveColumnActions = ({ const colIdx = columns.findIndex((col) => col.id === column.id); + // UX polish: prevent the column actions hover animation from flashing after column move + const handleAnimationFlash = () => { + setIsColumnMoving(true); + requestAnimationFrame(() => setIsColumnMoving(false)); + }; + const moveFocus = (direction: 'left' | 'right') => { const newIndex = direction === 'left' ? -1 : 1; // Wait a beat to move focus, otherwise the EuiPopover's EuiFocusTrap's @@ -171,6 +462,7 @@ const getMoveColumnActions = ({ const targetCol = columns[colIdx - 1]; if (targetCol) { switchColumnPos(column.id, targetCol.id); + handleAnimationFlash(); moveFocus('left'); } }; @@ -191,6 +483,7 @@ const getMoveColumnActions = ({ const targetCol = columns[colIdx + 1]; if (targetCol) { switchColumnPos(column.id, targetCol.id); + handleAnimationFlash(); moveFocus('right'); } }; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.styles.ts b/packages/eui/src/components/datagrid/body/header/column_resizer.styles.ts similarity index 79% rename from packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.styles.ts rename to packages/eui/src/components/datagrid/body/header/column_resizer.styles.ts index 840c371787f..8587677ad38 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.styles.ts +++ b/packages/eui/src/components/datagrid/body/header/column_resizer.styles.ts @@ -50,19 +50,18 @@ export const euiDataGridColumnResizerStyles = ( ${logicalCSS('width', indicatorWidth)} background-color: ${euiTheme.colors.primary}; } + `, + /* Because the resizer sits in the negative space to the right of the column, + * it can cause the full grid to be a few pixels longer than it actually is. + * So for the last cell, we don't use negative positioning and the borders from + * the cell will match the container. */ + isLastColumn: css` + ${logicalCSS('right', 0)} + ${logicalCSS('width', euiTheme.size.s)} - /* Because the resizer sits in the negative space to the right of the column, - * it can cause the full grid to be a few pixels longer than it actually is. - * So for the last cell, we don't use negative positioning and the borders from - * the cell will match the container. */ - .euiDataGridHeaderCell:last-child & { + &::after { + ${logicalCSS('left', 'auto')} ${logicalCSS('right', 0)} - ${logicalCSS('width', euiTheme.size.s)} - - &::after { - ${logicalCSS('left', 'auto')} - ${logicalCSS('right', 0)} - } } `, isDragging: css` diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.test.tsx b/packages/eui/src/components/datagrid/body/header/column_resizer.test.tsx similarity index 97% rename from packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.test.tsx rename to packages/eui/src/components/datagrid/body/header/column_resizer.test.tsx index a9c77fbaa1c..63e19107d54 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/column_resizer.test.tsx @@ -10,13 +10,14 @@ import React from 'react'; import { act } from '@testing-library/react'; import { render } from '../../../../test/rtl'; -import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; +import { EuiDataGridColumnResizer } from './column_resizer'; describe('EuiDataGridHeaderResizer', () => { const props = { columnId: 'someColumn', columnWidth: 50, setColumnWidth: jest.fn(), + isLastColumn: false, }; it('renders', () => { diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.tsx b/packages/eui/src/components/datagrid/body/header/column_resizer.tsx similarity index 84% rename from packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.tsx rename to packages/eui/src/components/datagrid/body/header/column_resizer.tsx index 158e5ea49a4..2ab51d4cad9 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_column_resizer.tsx +++ b/packages/eui/src/components/datagrid/body/header/column_resizer.tsx @@ -13,7 +13,8 @@ import { EuiDataGridColumnResizerProps, EuiDataGridColumnResizerState, } from '../../data_grid_types'; -import { euiDataGridColumnResizerStyles } from './data_grid_column_resizer.styles'; +import { DragOverlay } from './draggable_columns'; +import { euiDataGridColumnResizerStyles } from './column_resizer.styles'; const MINIMUM_COLUMN_WIDTH = 40; @@ -66,6 +67,7 @@ export class EuiDataGridColumnResizer extends Component< render() { const { offset } = this.state; + const { isLastColumn } = this.props; return ( @@ -73,6 +75,7 @@ export class EuiDataGridColumnResizer extends Component< const styles = stylesMemoizer(euiDataGridColumnResizerStyles); const cssStyles = [ styles.euiDataGridColumnResizer, + isLastColumn && styles.isLastColumn, offset && styles.isDragging, ]; return ( @@ -86,7 +89,11 @@ export class EuiDataGridColumnResizer extends Component< : undefined } onMouseDown={this.onMouseDown} - /> + > + {/* UX polish: prevent other hover states from activating when + dragging over other elements + maintain the resize cursor */} + +
); }} diff --git a/packages/eui/src/components/datagrid/body/header/column_sorting.test.tsx b/packages/eui/src/components/datagrid/body/header/column_sorting.test.tsx new file mode 100644 index 00000000000..8fd83f73500 --- /dev/null +++ b/packages/eui/src/components/datagrid/body/header/column_sorting.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactNode } from 'react'; +import { render, renderHook } from '../../../../test/rtl'; + +import { useColumnSorting } from './column_sorting'; + +describe('useColumnSorting', () => { + const onSort = () => {}; + const columnId = 'test'; + const mockSortingArgs = { + sorting: undefined, + id: columnId, + hasColumnActions: true, + }; + + const getRender = (node: ReactNode) => render(<>{node}).container; + + describe('if the current column is being sorted', () => { + it('renders an ascending sort arrow', () => { + const { sortingArrow } = renderHook(() => + useColumnSorting({ + ...mockSortingArgs, + sorting: { + onSort, + columns: [{ id: columnId, direction: 'asc' }], + }, + }) + ).result.current; + + expect( + getRender(sortingArrow).querySelector('[data-euiicon-type="sortUp"]') + ).toBeInTheDocument(); + }); + + it('renders a descending sort arrow', () => { + const { sortingArrow } = renderHook(() => + useColumnSorting({ + ...mockSortingArgs, + sorting: { + onSort, + columns: [{ id: columnId, direction: 'desc' }], + }, + }) + ).result.current; + + expect( + getRender(sortingArrow).querySelector('[data-euiicon-type="sortDown"]') + ).toBeInTheDocument(); + }); + + describe('when only the current column is being sorted', () => { + describe('when the header cell has no actions', () => { + it('renders aria-sort but not sortingScreenReaderText', () => { + const { ariaSort, sortingScreenReaderText } = renderHook(() => + useColumnSorting({ + ...mockSortingArgs, + sorting: { + onSort, + columns: [{ id: columnId, direction: 'asc' }], + }, + hasColumnActions: false, + }) + ).result.current; + + expect(ariaSort).toEqual('ascending'); + expect(getRender(sortingScreenReaderText)).toHaveTextContent(''); + }); + }); + + describe('when the header cell has actions', () => { + it('renders aria-sort and sortingScreenReaderText', () => { + const { ariaSort, sortingScreenReaderText } = renderHook(() => + useColumnSorting({ + ...mockSortingArgs, + sorting: { + onSort, + columns: [{ id: columnId, direction: 'desc' }], + }, + hasColumnActions: true, + }) + ).result.current; + + expect(ariaSort).toEqual('descending'); + expect(getRender(sortingScreenReaderText)).toHaveTextContent( + 'Sorted descending.' + ); + }); + }); + }); + }); + + describe('if the current column is not being sorted', () => { + it('does not render an arrow even if other columns are sorted', () => { + const { sortingArrow } = renderHook(() => + useColumnSorting({ + ...mockSortingArgs, + sorting: { onSort, columns: [{ id: 'other', direction: 'desc' }] }, + }) + ).result.current; + + expect(sortingArrow).toBeNull(); + }); + + it('does not render aria-sort or screen reader sorting text', () => { + const { ariaSort, sortingScreenReaderText } = renderHook(() => + useColumnSorting(mockSortingArgs) + ).result.current; + + expect(ariaSort).toEqual(undefined); + expect(getRender(sortingScreenReaderText)).toHaveTextContent(''); + }); + }); + + describe('when multiple columns are being sorted', () => { + it('does not render aria-sort, but renders sorting screen reader text text with a full list of sorted columns', () => { + const { result, rerender } = renderHook(useColumnSorting, { + initialProps: { + ...mockSortingArgs, + id: 'A', + sorting: { + onSort, + columns: [ + { id: 'A', direction: 'asc' }, + { id: 'B', direction: 'desc' }, + ], + }, + }, + }); + + expect(result.current.ariaSort).toEqual(undefined); + expect( + getRender(result.current.sortingScreenReaderText) + ).toHaveTextContent( + 'Sorted by A, ascending, then sorted by B, descending.' + ); + + // Branch coverage + rerender({ + ...mockSortingArgs, + id: 'B', + sorting: { + onSort, + columns: [ + { id: 'B', direction: 'desc' }, + { id: 'C', direction: 'asc' }, + { id: 'A', direction: 'asc' }, + ], + }, + }); + expect( + getRender(result.current.sortingScreenReaderText) + ).toHaveTextContent( + 'Sorted by B, descending, then sorted by C, ascending, then sorted by A, ascending.' + ); + }); + }); +}); diff --git a/packages/eui/src/components/datagrid/body/header/column_sorting.tsx b/packages/eui/src/components/datagrid/body/header/column_sorting.tsx new file mode 100644 index 00000000000..d143d9fe92f --- /dev/null +++ b/packages/eui/src/components/datagrid/body/header/column_sorting.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { AriaAttributes, useMemo } from 'react'; + +import { useGeneratedHtmlId } from '../../../../services'; +// Keep the i18n scope the same as EuiDataGridHeaderCell +/* eslint-disable local/i18n */ +import { EuiI18n } from '../../../i18n'; +import { EuiIcon } from '../../../icon'; +import { EuiDataGridSorting } from '../../data_grid_types'; + +/** + * Column sorting utility helpers + */ +export const useColumnSorting = ({ + sorting, + id, + hasColumnActions, +}: { + sorting?: EuiDataGridSorting; + id: string; + hasColumnActions: boolean; +}) => { + const sortedColumn = useMemo( + () => sorting?.columns.find((col) => col.id === id), + [sorting, id] + ); + const isColumnSorted = !!sortedColumn; + const hasOnlyOneSort = sorting?.columns?.length === 1; + + /** + * Arrow icon + */ + const sortingArrow = useMemo(() => { + return isColumnSorted ? ( + + ) : null; + }, [id, isColumnSorted, sortedColumn]); + + /** + * aria-sort attribute - should only be used when a single column is being sorted + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort + * @see https://www.w3.org/WAI/ARIA/apg/example-index/table/sortable-table.html + * @see https://github.com/w3c/aria/issues/283 for potential future multi-column usage + */ + const ariaSort: AriaAttributes['aria-sort'] = + isColumnSorted && hasOnlyOneSort + ? sorting.columns[0].direction === 'asc' + ? 'ascending' + : 'descending' + : undefined; + + // aria-describedby ID for when aria-sort isn't sufficient + const sortingAriaId = useGeneratedHtmlId({ + prefix: 'euiDataGridCellHeader', + suffix: 'sorting', + }); + + /** + * Sorting status - screen reader text + */ + const sortingScreenReaderText = useMemo(() => { + if (!isColumnSorted) return null; + if (!hasColumnActions && hasOnlyOneSort) return null; // in this scenario, the `aria-sort` attribute will be used by screen readers + return ( + + ); + }, [ + isColumnSorted, + hasColumnActions, + hasOnlyOneSort, + sorting, + sortingAriaId, + ]); + + return { sortingArrow, ariaSort, sortingAriaId, sortingScreenReaderText }; +}; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx index 0ddbf329683..fca1d405b40 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx @@ -14,7 +14,7 @@ import { EuiDataGridControlHeaderCell } from './data_grid_control_header_cell'; describe('EuiDataGridControlHeaderCell', () => { const props = { index: 0, - visibleColCount: 1, + isLastColumn: true, controlColumn: { id: 'someControlColumn', headerCellRender: () => - - -
-); +import { EuiDataGridHeaderCell } from './data_grid_header_cell'; describe('EuiDataGridHeaderCell', () => { const requiredProps = { @@ -38,15 +17,16 @@ describe('EuiDataGridHeaderCell', () => { id: 'someColumn', }, index: 0, + isLastColumn: true, columns: [], columnWidths: {}, defaultColumnWidth: 50, schema: { someColumn: { columnType: 'numeric' } }, schemaDetectors: [], setColumnWidth: jest.fn(), - visibleColCount: 1, setVisibleColumns: jest.fn(), switchColumnPos: jest.fn(), + gridStyles: { header: 'shade' as const }, }; it('renders', () => { @@ -54,158 +34,14 @@ describe('EuiDataGridHeaderCell', () => { expect(container.firstChild).toMatchSnapshot(); }); - describe('sorting', () => { - const onSort = () => {}; - const columnId = 'test'; - const mockSortingArgs = { - sorting: undefined, - id: columnId, - showColumnActions: true, - }; - - const getRender = (node: ReactNode) => render(<>{node}).container; - - describe('if the current column is being sorted', () => { - it('renders an ascending sort arrow', () => { - const { sortingArrow } = renderHook(() => - useSortingUtils({ - ...mockSortingArgs, - sorting: { - onSort, - columns: [{ id: columnId, direction: 'asc' }], - }, - }) - ).result.current; - - expect( - getRender(sortingArrow).querySelector('[data-euiicon-type="sortUp"]') - ).toBeInTheDocument(); - }); - - it('renders a descending sort arrow', () => { - const { sortingArrow } = renderHook(() => - useSortingUtils({ - ...mockSortingArgs, - sorting: { - onSort, - columns: [{ id: columnId, direction: 'desc' }], - }, - }) - ).result.current; - - expect( - getRender(sortingArrow).querySelector( - '[data-euiicon-type="sortDown"]' - ) - ).toBeInTheDocument(); - }); - - describe('when only the current column is being sorted', () => { - describe('when the header cell has no actions', () => { - it('renders aria-sort but not sortingScreenReaderText', () => { - const { ariaSort, sortingScreenReaderText } = renderHook(() => - useSortingUtils({ - ...mockSortingArgs, - sorting: { - onSort, - columns: [{ id: columnId, direction: 'asc' }], - }, - showColumnActions: false, - }) - ).result.current; - - expect(ariaSort).toEqual('ascending'); - expect(getRender(sortingScreenReaderText)).toHaveTextContent(''); - }); - }); - - describe('when the header cell has actions', () => { - it('renders aria-sort and sortingScreenReaderText', () => { - const { ariaSort, sortingScreenReaderText } = renderHook(() => - useSortingUtils({ - ...mockSortingArgs, - sorting: { - onSort, - columns: [{ id: columnId, direction: 'desc' }], - }, - showColumnActions: true, - }) - ).result.current; - - expect(ariaSort).toEqual('descending'); - expect(getRender(sortingScreenReaderText)).toHaveTextContent( - 'Sorted descending.' - ); - }); - }); - }); - }); - - describe('if the current column is not being sorted', () => { - it('does not render an arrow even if other columns are sorted', () => { - const { sortingArrow } = renderHook(() => - useSortingUtils({ - ...mockSortingArgs, - sorting: { onSort, columns: [{ id: 'other', direction: 'desc' }] }, - }) - ).result.current; - - expect(sortingArrow).toBeNull(); - }); - - it('does not render aria-sort or screen reader sorting text', () => { - const { ariaSort, sortingScreenReaderText } = renderHook(() => - useSortingUtils(mockSortingArgs) - ).result.current; - - expect(ariaSort).toEqual(undefined); - expect(getRender(sortingScreenReaderText)).toHaveTextContent(''); - }); - }); - - describe('when multiple columns are being sorted', () => { - it('does not render aria-sort, but renders sorting screen reader text text with a full list of sorted columns', () => { - const { result, rerender } = renderHook(useSortingUtils, { - initialProps: { - ...mockSortingArgs, - id: 'A', - sorting: { - onSort, - columns: [ - { id: 'A', direction: 'asc' }, - { id: 'B', direction: 'desc' }, - ], - }, - }, - }); - - expect(result.current.ariaSort).toEqual(undefined); - expect( - getRender(result.current.sortingScreenReaderText) - ).toHaveTextContent( - 'Sorted by A, ascending, then sorted by B, descending.' - ); - - // Branch coverage - rerender({ - ...mockSortingArgs, - id: 'B', - sorting: { - onSort, - columns: [ - { id: 'B', direction: 'desc' }, - { id: 'C', direction: 'asc' }, - { id: 'A', direction: 'asc' }, - ], - }, - }); - expect( - getRender(result.current.sortingScreenReaderText) - ).toHaveTextContent( - 'Sorted by B, descending, then sorted by C, ascending, then sorted by A, ascending.' - ); - }); - }); + it('does not render a popover if there are no column actions', () => { + const { container } = render( + + ); + expect(container.querySelector('.euiPopover')).not.toBeInTheDocument(); }); describe('resizing', () => { @@ -241,153 +77,4 @@ describe('EuiDataGridHeaderCell', () => { ).not.toBeInTheDocument(); }); }); - - describe('popover', () => { - it('does not render a popover if there are no column actions', () => { - const { container } = render( - - ); - expect(container.querySelector('.euiPopover')).not.toBeInTheDocument(); - }); - - it('handles popover open and close', () => { - const { container } = render( - - - - ); - const toggle = container.querySelector('.euiDataGridHeaderCell__button')!; - - fireEvent.click(toggle); - waitForEuiPopoverOpen(); - - fireEvent.click(toggle); - waitForEuiPopoverClose(); - }); - - describe('keyboard arrow navigation', () => { - const { - panelRef, - panelProps: { onKeyDown }, - } = renderHook(usePopoverArrowNavigation).result.current; - - let mockPanel: HTMLElement; - - const preventDefault = jest.fn(); - const keyDownEvent = { preventDefault } as unknown as React.KeyboardEvent; - beforeEach(() => jest.clearAllMocks()); - - describe('early returns', () => { - it('does nothing if the up/down arrow keys are not pressed', () => { - onKeyDown({ ...keyDownEvent, key: 'Tab' }); - expect(preventDefault).not.toHaveBeenCalled(); - }); - - it('does nothing if the popover contains no tabbable elements', () => { - const emptyDiv = document.createElement('div'); - panelRef(emptyDiv); - onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); - expect(preventDefault).not.toHaveBeenCalled(); - - panelRef(mockPanel); // Reset for other tests - }); - }); - - describe('when the popover panel is focused (on initial open state)', () => { - beforeEach(() => { - const { container } = render(); - - mockPanel = container.firstElementChild as HTMLElement; - panelRef(mockPanel); - - mockPanel.focus(); - }); - it('focuses the first action when the arrow down key is pressed', () => { - expect(mockPanel).toHaveFocus(); - onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); - expect(preventDefault).toHaveBeenCalled(); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('first'); - }); - - it('focuses the last action when the arrow up key is pressed', () => { - onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); - expect(preventDefault).toHaveBeenCalled(); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('last'); - }); - }); - - describe('when already focused on action buttons', () => { - describe('down arrow key', () => { - beforeEach(() => { - const { container } = render(); - - mockPanel = container.firstElementChild as HTMLElement; - panelRef(mockPanel); - }); - - it('moves focus to the the next action', () => { - (mockPanel.firstElementChild as HTMLButtonElement).focus(); - - onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('second'); - - onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('last'); - }); - - it('loops focus back to the first action when pressing down on the last action', () => { - (mockPanel.lastElementChild as HTMLButtonElement).focus(); - - onKeyDown({ ...keyDownEvent, key: 'ArrowDown' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('first'); - }); - }); - - describe('up arrow key', () => { - beforeEach(() => { - const { container } = render(); - - mockPanel = container.firstElementChild as HTMLElement; - panelRef(mockPanel); - }); - - it('moves focus to the previous action', () => { - (mockPanel.lastElementChild as HTMLButtonElement).focus(); - - onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('second'); - - onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('first'); - }); - - it('loops focus back to the last action when pressing up on the first action', () => { - (mockPanel.firstElementChild as HTMLButtonElement).focus(); - - onKeyDown({ ...keyDownEvent, key: 'ArrowUp' }); - expect( - document.activeElement?.getAttribute('data-test-subj') - ).toEqual('last'); - }); - }); - }); - }); - }); }); diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx index 94ddc03fed8..239ab2e9d73 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx @@ -8,34 +8,25 @@ import classnames from 'classnames'; import React, { - AriaAttributes, FunctionComponent, - useContext, - useState, - useRef, - useCallback, - useMemo, memo, + useMemo, + useRef, + useState, } from 'react'; -import { tabbable, FocusableElement } from 'tabbable'; -import { - keys, - useGeneratedHtmlId, - useEuiMemoizedStyles, -} from '../../../../services'; -import { EuiI18n, useEuiI18n } from '../../../i18n'; + +import { useEuiMemoizedStyles } from '../../../../services'; import { EuiIcon } from '../../../icon'; -import { EuiListGroup } from '../../../list_group'; -import { EuiPopover } from '../../../popover'; -import { EuiButtonIcon } from '../../../button'; +import { EuiDataGridHeaderCellProps } from '../../data_grid_types'; -import { DataGridFocusContext } from '../../utils/focus'; import { - EuiDataGridHeaderCellProps, - EuiDataGridSorting, -} from '../../data_grid_types'; -import { getColumnActions } from './column_actions'; -import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; + useHasColumnActions, + ColumnActions, + PropsFromColumnActions, +} from './column_actions'; +import { useColumnSorting } from './column_sorting'; +import { ConditionalDraggableColumn } from './draggable_columns'; +import { EuiDataGridColumnResizer } from './column_resizer'; import { EuiDataGridHeaderCellWrapper } from './data_grid_header_cell_wrapper'; import { euiDataGridHeaderCellStyles } from './data_grid_header_cell.styles'; @@ -48,101 +39,27 @@ export const EuiDataGridHeaderCell: FunctionComponent { - const { id, display, displayAsText, displayHeaderCellProps } = column; + const { id, display, displayAsText, displayHeaderCellProps, actions } = + column; const title = displayAsText || id; const children = display || displayAsText || id; const width = columnWidths[id] || defaultColumnWidth; const columnType = schema[id] ? schema[id].columnType : null; + const hasColumnActions = useHasColumnActions(actions); - const { setFocusedCell, focusFirstVisibleInteractiveCell } = - useContext(DataGridFocusContext); - - /* - * Column actions - */ - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const togglePopover = useCallback(() => { - setIsPopoverOpen((isOpen) => !isOpen); - }, []); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); - const popoverArrowNavigationProps = usePopoverArrowNavigation(); - - const columnActions = useMemo(() => { - return getColumnActions({ - column, - columns, - schema, - schemaDetectors, - setVisibleColumns, - focusFirstVisibleInteractiveCell, - setIsPopoverOpen, - sorting, - switchColumnPos, - setFocusedCell, - columnFocusIndex: index, - }); - }, [ - column, - columns, - schema, - schemaDetectors, - setVisibleColumns, - focusFirstVisibleInteractiveCell, - setIsPopoverOpen, - sorting, - switchColumnPos, - setFocusedCell, - index, - ]); - - const showColumnActions = columnActions && columnActions.length > 0; - const actionsButtonRef = useRef(null); - const clickActionsButton = useCallback(() => { - actionsButtonRef.current?.click(); - }, []); - const [isActionsButtonFocused, setIsActionsButtonFocused] = - useState(false); - - const actionsButtonAriaLabel = useEuiI18n( - 'euiDataGridHeaderCell.actionsButtonAriaLabel', - '{title}. Click to view column header actions.', - { title } - ); - const actionsEnterKeyInstructions = useEuiI18n( - 'euiDataGridHeaderCell.actionsEnterKeyInstructions', - "Press the Enter key to view this column's actions" - ); - - /* - * Column sorting - */ - const { sortingArrow, ariaSort, sortingScreenReaderText } = - useSortingUtils({ - sorting, - id, - showColumnActions, - }); - - const sortingAriaId = useGeneratedHtmlId({ - prefix: 'euiDataGridCellHeader', - suffix: 'sorting', - }); - - /* - * Rendering - */ const classes = classnames( { [`euiDataGridHeaderCell--${columnType}`]: columnType, - 'euiDataGridHeaderCell--hasColumnActions': showColumnActions, - 'euiDataGridHeaderCell--isActionsPopoverOpen': isPopoverOpen, + 'euiDataGridHeaderCell--hasColumnActions': hasColumnActions, }, displayHeaderCellProps?.className ); @@ -153,275 +70,109 @@ export const EuiDataGridHeaderCell: FunctionComponent to be set on + const [propsFromColumnActions, setPropsFromColumnActions] = useState< + Partial + >({}); + const actionsButtonRef = useRef(null); + + const { sortingArrow, ariaSort, sortingAriaId, sortingScreenReaderText } = + useColumnSorting({ + sorting, + id, + hasColumnActions, + }); + + const columnResizer = useMemo(() => { + return column.isResizable !== false && width != null ? ( + + ) : null; + }, [column.isResizable, id, width, setColumnWidth, isLastColumn]); + return ( - - {(hasFocusTrap) => ( - <> - {column.isResizable !== false && width != null ? ( - - ) : null} - -
- {children} -
- {sortingArrow} - - {sortingScreenReaderText && ( - + {(dragProps) => ( + + {(hasFocusTrap) => ( + <> + {!canDragAndDropColumns && columnResizer} + {canDragAndDropColumns && ( + + + + )} + +
+ {children} +
+ + {sortingArrow} + {sortingScreenReaderText} - {showColumnActions && ( - setIsActionsButtonFocused(true)} - onBlur={() => setIsActionsButtonFocused(false)} - tabIndex={0} // Override EuiButtonIcon's conditional tabindex based on aria-hidden - aria-hidden={ - hasFocusTrap && !isActionsButtonFocused - ? 'true' // prevent the actions button from being read on cell focus - : undefined - } - aria-label={ - hasFocusTrap - ? actionsButtonAriaLabel - : actionsEnterKeyInstructions - } - data-test-subj={`dataGridHeaderCellActionButton-${id}`} + {hasColumnActions && ( + - } - isOpen={isPopoverOpen} - closePopover={closePopover} - {...popoverArrowNavigationProps} - > - - + )} + )} - +
)} -
+ ); } ); EuiDataGridHeaderCell.displayName = 'EuiDataGridHeaderCell'; - -/** - * Column sorting utility helpers - */ -export const useSortingUtils = ({ - sorting, - id, - showColumnActions, -}: { - sorting?: EuiDataGridSorting; - id: string; - showColumnActions: boolean; -}) => { - const sortedColumn = useMemo( - () => sorting?.columns.find((col) => col.id === id), - [sorting, id] - ); - const isColumnSorted = !!sortedColumn; - const hasOnlyOneSort = sorting?.columns?.length === 1; - - /** - * Arrow icon - */ - const sortingArrow = useMemo(() => { - return isColumnSorted ? ( - - ) : null; - }, [id, isColumnSorted, sortedColumn]); - - /** - * aria-sort attribute - should only be used when a single column is being sorted - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort - * @see https://www.w3.org/WAI/ARIA/apg/example-index/table/sortable-table.html - * @see https://github.com/w3c/aria/issues/283 for potential future multi-column usage - */ - const ariaSort: AriaAttributes['aria-sort'] = - isColumnSorted && hasOnlyOneSort - ? sorting.columns[0].direction === 'asc' - ? 'ascending' - : 'descending' - : undefined; - - /** - * Sorting status - screen reader text - */ - const sortingScreenReaderText = useMemo(() => { - if (!isColumnSorted) return null; - if (!showColumnActions && hasOnlyOneSort) return null; // in this scenario, the `aria-sort` attribute will be used by screen readers - return ( - <> - {sorting?.columns?.map(({ id: columnId, direction }, index) => { - if (hasOnlyOneSort) { - if (direction === 'asc') { - return ( - - ); - } else { - return ( - - ); - } - } else if (index === 0) { - if (direction === 'asc') { - return ( - - ); - } else { - return ( - - ); - } - } else { - if (direction === 'asc') { - return ( - - ); - } else { - return ( - - ); - } - } - })} - . - - ); - }, [isColumnSorted, showColumnActions, hasOnlyOneSort, sorting]); - - return { sortingArrow, ariaSort, sortingScreenReaderText }; -}; - -/** - * Add keyboard arrow navigation to the cell actions popover - * to match the UX of the rest of EuiDataGrid - */ -export const usePopoverArrowNavigation = () => { - const popoverPanelRef = useRef(null); - const actionsRef = useRef(undefined); - const panelRef = useCallback((ref: HTMLElement | null) => { - popoverPanelRef.current = ref; - actionsRef.current = ref ? tabbable(ref) : undefined; - }, []); - - const onKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key !== keys.ARROW_DOWN && e.key !== keys.ARROW_UP) return; - if (!actionsRef.current?.length) return; - - e.preventDefault(); - - const initialState = document.activeElement === popoverPanelRef.current; - const currentIndex = !initialState - ? actionsRef.current.findIndex((el) => document.activeElement === el) - : -1; - const lastIndex = actionsRef.current.length - 1; - - let indexToFocus: number; - if (initialState) { - if (e.key === keys.ARROW_DOWN) { - indexToFocus = 0; - } else if (e.key === keys.ARROW_UP) { - indexToFocus = lastIndex; - } - } else { - if (e.key === keys.ARROW_DOWN) { - indexToFocus = currentIndex + 1; - if (indexToFocus > lastIndex) { - indexToFocus = 0; - } - } else if (e.key === keys.ARROW_UP) { - indexToFocus = currentIndex - 1; - if (indexToFocus < 0) { - indexToFocus = lastIndex; - } - } - } - - actionsRef.current[indexToFocus!].focus(); - }, []); - - return { - panelRef, - panelProps: { onKeyDown }, - popoverScreenReaderText: ( - - ), - }; -}; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts index 61cbac4c334..e83fb425bd5 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts @@ -27,16 +27,11 @@ export const euiDataGridHeaderCellWrapperStyles = ( euiDataGridCellOutlineStyles(euiThemeContext); const { header: outlineSelectors } = euiDataGridCellOutlineSelectors(); - const _sharedFlexCss = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.xxs}; - `; - return { euiDataGridHeaderCell: css` position: relative; /* Needed for cell outline */ - ${_sharedFlexCss} + display: flex; + align-items: center; flex: 0 0 auto; font-weight: ${euiTheme.font.weight.bold}; @@ -48,9 +43,17 @@ export const euiDataGridHeaderCellWrapperStyles = ( ${hoverStyles} } + ${outlineSelectors.showActions} { + &, + & > [data-focus-lock-disabled] { + gap: ${euiTheme.size.xxs}; + } + } + /* Workaround for focus trap */ & > [data-focus-lock-disabled] { - ${_sharedFlexCss} + display: flex; + align-items: center; ${logicalCSS('width', '100%')} } `, diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx index 5af142ed960..c1a5f32170a 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx @@ -18,8 +18,8 @@ describe('EuiDataGridHeaderCellWrapper', () => { const requiredProps = { id: 'someColumn', index: 0, - visibleColCount: 1, - hasActionsPopover: true, + isLastColumn: true, + hasColumnActions: true, children: ( <> Some Column diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx index 63877240d6a..99d2f073cfc 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx @@ -37,12 +37,13 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< > = ({ id, index, - visibleColCount, + isLastColumn, width, className, children, - hasActionsPopover, - openActionsPopover, + hasColumnActions, + isDragging, + onKeyDown: _onKeyDown, 'aria-label': ariaLabel, ...rest }) => { @@ -57,10 +58,8 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< >([]); useEffect(() => { // We're checking for interactive children outside of the default actions button - setRenderFocusTrap( - interactiveChildren.length > (hasActionsPopover ? 1 : 0) - ); - }, [hasActionsPopover, interactiveChildren]); + setRenderFocusTrap(interactiveChildren.length > (hasColumnActions ? 1 : 0)); + }, [hasColumnActions, interactiveChildren]); const { setFocusedCell, onFocusUpdate } = useContext(DataGridFocusContext); const updateCellFocusContext = useCallback(() => { @@ -80,23 +79,18 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< }); }, [index, onFocusUpdate, headerEl]); - // For cell headers with only actions, auto-open the actions popover on enter keypress const onKeyDown: KeyboardEventHandler = useCallback( (e) => { - if ( - e.key === keys.ENTER && - hasActionsPopover && - !renderFocusTrap && - e.target === headerEl - ) { - openActionsPopover?.(); + // Ignore keys that conflict with the focus trap being entered/exited + if (renderFocusTrap && (e.key === keys.ENTER || e.key === keys.ESCAPE)) { + return; } + // Otherwise, continue with whatever onKeyDown is being passed + _onKeyDown?.(e); }, - [hasActionsPopover, openActionsPopover, renderFocusTrap, headerEl] + [_onKeyDown, renderFocusTrap] ); - const isLastColumn = index === visibleColCount - 1; - return (
{typeof children === 'function' ? children(renderFocusTrap) : children} diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx index 9d98e4ff0b8..f03d672b09f 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx @@ -66,7 +66,7 @@ describe('EuiDataGridHeaderRow', () => { tabindex="-1" >
{ > }, + ]; + + const StatefulDataGrid = (props: Partial) => { + const [visibleColumns, setVisibleColumns] = useState(['a', 'b']); + + return ( + `${columnId}, ${rowIndex}`} + {...props} + columns={columns} + columnVisibility={{ + visibleColumns, + setVisibleColumns, + canDragAndDropColumns: true, + }} + // ExclusiveUnion shenanigans :| + aria-label="Testing" + aria-labelledby={undefined} + /> + ); + }; + + it('should reorder columns on header cell drag and drop', () => { + cy.realMount(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]') + .realHover() + .realMouseDown({ position: 'center' }) + .realMouseMove(0, 0) // start drag + .realMouseMove(200, 34) // move (absolute coordinates) + .realMouseUp(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').should( + 'have.attr', + 'data-gridcell-column-index', + '1' + ); + cy.get('[data-test-subj=dataGridHeaderCell-b]').should( + 'have.attr', + 'data-gridcell-column-index', + '0' + ); + + cy.get('[data-test-subj=dataGridHeaderCell-a]') + .realHover() + .realMouseDown({ position: 'center' }) + .realMouseMove(0, 0) // start drag + .realMouseMove(0, 34) // move (absolute coordinates) + .realMouseUp(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').should( + 'have.attr', + 'data-gridcell-column-index', + '0' + ); + cy.get('[data-test-subj=dataGridHeaderCell-b]').should( + 'have.attr', + 'data-gridcell-column-index', + '1' + ); + }); + + it('should reorder columns on header cell drag and drop with keyboard', () => { + cy.realMount(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]') + .focus() + .realPress('Space') + .realPress('ArrowRight') + .realPress('Space'); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').should( + 'have.attr', + 'data-gridcell-column-index', + '1' + ); + cy.get('[data-test-subj=dataGridHeaderCell-b]').should( + 'have.attr', + 'data-gridcell-column-index', + '0' + ); + + cy.realPress('Space'); + cy.realPress('ArrowLeft'); + cy.realPress('Space'); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').should( + 'have.attr', + 'data-gridcell-column-index', + '0' + ); + cy.get('[data-test-subj=dataGridHeaderCell-b]').should( + 'have.attr', + 'data-gridcell-column-index', + '1' + ); + }); + + it('should refocus cells on drag cancel', () => { + cy.realMount(); + + // Keyboard drag cancel + cy.get('[data-test-subj=dataGridHeaderCell-a]') + .realClick() + .realPress('Space') + .realPress('Escape'); + cy.focused() + .should('have.attr', 'data-test-subj', 'dataGridHeaderCell-a') + .should('have.attr', 'data-gridcell-column-index', '0'); + + // Should be able to tab in and out of the grid and have focus be restored to the correct header cell + cy.realPress(['Shift', 'Tab']); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridFullScreenButton' + ); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-test-subj', 'dataGridHeaderCell-a'); + + // Mouse drag cancel + cy.get('[data-test-subj=dataGridHeaderCell-a]') + .realHover() + .realMouseDown({ position: 'center' }) + .realMouseMove(0, 0) // start drag + .realMouseMove(0, 34) // move (absolute coordinates) + .realMouseUp(); + cy.wait(100); + cy.focused().should('have.attr', 'data-test-subj', 'dataGridHeaderCell-a'); + }); + + describe('headers with interactive children', () => { + it('should not interfere with focus traps', () => { + cy.realMount(); + + cy.get('[data-test-subj=dataGridHeaderCell-b]') + .focus() + .realPress('Enter'); + + cy.focused().should('have.text', 'Second'); + cy.realPress('Tab'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCellActionButton-b' + ); + cy.realPress('Tab'); + cy.focused().should('have.text', 'Second'); + + cy.realPress('Escape'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCell-b' + ); + }); + + it('correctly re-focuses dragged cells if an interactive child has focus on drag start', () => { + cy.realMount(); + + cy.get('[data-test-subj="b"]').focus(); + cy.focused().should('have.attr', 'data-test-subj', 'b'); + + cy.get('[data-test-subj=dataGridHeaderCell-b]') + .realMouseDown({ position: 'center' }) + .realMouseMove(0, 0) // start drag + .realMouseMove(0, 34) // move (absolute coordinates) + .realMouseUp(); + + cy.focused() + .should('have.attr', 'data-test-subj', 'dataGridHeaderCell-b') + .should('have.attr', 'tabindex', '0'); + }); + + it('should not open focus traps when dragging', () => { + cy.realMount(); + + // Start drag + cy.get('[data-test-subj=dataGridHeaderCell-b]') + .focus() + .realPress('Space'); + + // Should not enter focus trap + cy.realPress('Enter'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCell-b' + ); + + // Cancel drag + cy.realPress('Escape'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCell-b' + ); + + // Should still be able to enter focus trap + cy.realPress('Enter'); + cy.focused().should('have.text', 'Second'); + }); + }); + + describe('clicking a draggable cell', () => { + it('should close its own column actions popover', () => { + cy.realMount(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').realClick(); + cy.realPress('Enter'); + cy.get('[data-popover-open]').should('have.focus'); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').realClick(); + cy.get('[data-test-subj=dataGridHeaderCell-a]').should('have.focus'); + cy.get('[data-popover-open]').should('not.exist'); + + // Should not interefere with column actions popover toggle + cy.wait(250); + cy.get('[data-test-subj=dataGridHeaderCellActionButton-a]').realClick(); + cy.get('[data-popover-open]').should('have.focus'); + cy.get('[data-test-subj=dataGridHeaderCellActionButton-a]').realClick(); + cy.get('[data-popover-open]').should('not.exist'); + }); + + it("should close another column's actions popover", () => { + cy.realMount(); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').realHover(); + cy.wait(50); // wait until actions button transition is progressed enough for the button to be clickable + cy.get('[data-test-subj=dataGridHeaderCellActionButton-a]').realClick(); + cy.get('[data-popover-open]').should('have.focus'); + + cy.get('[data-test-subj=dataGridHeaderCell-b]').realClick(); + cy.get('[data-test-subj=dataGridHeaderCell-b]').should('have.focus'); + cy.get('[data-popover-open]').should('not.exist'); + }); + + it('should close row cell expansion popovers', () => { + cy.realMount(); + + cy.get( + '[data-gridcell-column-index=1][data-gridcell-row-index=0]' + ).realClick(); + cy.realPress('Enter'); + cy.get('[data-popover-open]').should('have.focus'); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').realClick({ + position: 'right', + }); + cy.get('[data-test-subj=dataGridHeaderCell-a]').should('have.focus'); + cy.get('[data-popover-open]').should('not.exist'); + }); + + it('should close data grid toolbar popovers', () => { + cy.realMount(); + + cy.get('[data-test-subj="dataGridColumnSelectorButton"]').realClick(); + cy.get('[data-popover-open]').should('have.focus'); + + cy.get('[data-test-subj=dataGridHeaderCell-a]').realClick({ + position: 'right', + }); + cy.get('[data-test-subj=dataGridHeaderCell-a]').should('have.focus'); + cy.get('[data-popover-open]').should('not.exist'); + }); + }); +}); diff --git a/packages/eui/src/components/datagrid/body/header/draggable_columns.styles.ts b/packages/eui/src/components/datagrid/body/header/draggable_columns.styles.ts new file mode 100644 index 00000000000..df36f2eea60 --- /dev/null +++ b/packages/eui/src/components/datagrid/body/header/draggable_columns.styles.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css, keyframes } from '@emotion/react'; + +import { UseEuiTheme } from '../../../../services'; +import { euiCanAnimate, logicalCSS } from '../../../../global_styling'; + +export const euiDataGridDraggableHeaderStyles = ({ euiTheme }: UseEuiTheme) => { + return { + euiDataGridHeaderDroppable: css` + display: flex; + `, + + // The resizer must be positioned outside the draggable component to ensure both work independently + euiDataGridHeaderCellDraggableWrapper: css` + position: relative; + + .euiDataGridColumnResizer::after { + ${logicalCSS('margin-left', `-${euiTheme.border.width.thick}`)} + } + `, + + // override internal styling from @hello-pangea/dnd to ensure positioning + euiDataGridHeaderCellDraggable: css` + display: flex; + ${logicalCSS('height', '100%')} + `, + // Add more visual affordance to keyboard drags (raises cell slightly to show green droppable bg) + // Using animation as transition doesn't seem to work (a tale as old as EuiDataGrid...) + isKeyboardDragging: css` + animation-name: ${keyframes` + from { transform: translateY(0); } + to { transform: translateY(-${euiTheme.size.s}); } + `}; + animation-iteration-count: 1; + animation-fill-mode: forwards; + + ${euiCanAnimate} { + animation-duration: ${euiTheme.animation.fast}; + } + `, + // Ensure correct cell background colors on drag + underline: css` + background-color: ${euiTheme.colors.emptyShade}; + `, + shade: css` + background-color: ${euiTheme.colors.lightestShade}; + `, + noLeadingBorder: css` + ${logicalCSS('border-left', 'none !important')} + `, + }; +}; diff --git a/packages/eui/src/components/datagrid/body/header/draggable_columns.tsx b/packages/eui/src/components/datagrid/body/header/draggable_columns.tsx new file mode 100644 index 00000000000..7c754013184 --- /dev/null +++ b/packages/eui/src/components/datagrid/body/header/draggable_columns.tsx @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + CSSProperties, + FunctionComponent, + MouseEventHandler, + KeyboardEventHandler, + ComponentProps, + ReactElement, + ReactNode, + memo, + useCallback, + useContext, + useRef, +} from 'react'; +import { + OnDragEndResponder, + DraggableProvidedDragHandleProps, +} from '@hello-pangea/dnd'; + +import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../../../services'; +import { + EuiDragDropContext, + EuiDroppable, + EuiDroppableProps, + EuiDraggable, +} from '../../../drag_and_drop'; +import { EuiPortal } from '../../../portal'; + +import { DataGridFocusContext } from '../../utils/focus'; +import { + EuiDataGridHeaderRowProps, + EuiDataGridStyle, +} from '../../data_grid_types'; +import { euiDataGridStyles } from '../../data_grid.styles'; +import { euiDataGridDraggableHeaderStyles } from './draggable_columns.styles'; + +/** + * Parent context + EuiDroppable wrapper + */ +export const DroppableColumns: FunctionComponent< + Pick & { + indexOffset: number; + children: EuiDroppableProps['children']; + } +> = memo(({ columns, switchColumnPos, indexOffset, children }) => { + const styles = useEuiMemoizedStyles(euiDataGridDraggableHeaderStyles); + const droppableId = useGeneratedHtmlId({ + prefix: 'euiDataGridHeaderDroppable', + }); + + const { setFocusedCell } = useContext(DataGridFocusContext); + + const handleOnDragEnd: OnDragEndResponder = useCallback( + ({ source, destination }) => { + if (!source) return; + + if (destination && destination.index !== source.index) { + const sourceColumn = columns[source.index - indexOffset]; + const destinationColumn = columns[destination.index - indexOffset]; + + if (sourceColumn && destinationColumn) { + switchColumnPos(sourceColumn.id, destinationColumn.id); + } + } + // Force focus the cell to prevent the datagrid body from become unfocusable, including on drag cancel + setTimeout(() => { + const cellToFocus = destination ? destination.index : source.index; + setFocusedCell([cellToFocus, -1], true); + }); + }, + [columns, indexOffset, switchColumnPos, setFocusedCell] + ); + + return ( + + + {children} + + + ); +}); +DroppableColumns.displayName = 'DroppableColumns'; + +/** + * Individual EuiDraggable columns + */ +export const DraggableColumn: FunctionComponent<{ + id: string; + index: number; + gridStyles: EuiDataGridStyle; + columnResizer?: ReactNode; + actionsPopoverToggle?: HTMLButtonElement | null; + children: ( + dragProps?: Partial & { + 'data-column-moving'?: boolean; + } + ) => ReactElement; +}> = memo( + ({ + id, + index, + gridStyles, + columnResizer, + actionsPopoverToggle, + children, + }) => { + const dataGridStyles = useEuiMemoizedStyles(euiDataGridStyles); + const styles = useEuiMemoizedStyles(euiDataGridDraggableHeaderStyles); + // Manually re-apply background and border overrides, since + // the droppable zone sets its own and confuses :first-of-type CSS + const reapplyCellStyles = [ + styles[gridStyles.header!], + index !== 0 && styles.noLeadingBorder, + ]; + + // Draggable prevents the cell from receiving focus on click. + // We manually ensure focus is set on cell mouseDown which + // also includes setting focus before dragging + const { setFocusedCell } = useContext(DataGridFocusContext); + const handleOnMouseDown: MouseEventHandler = useCallback( + (e) => { + const openFocusTrap = document.querySelector( + '[data-focus-lock-disabled="false"]' + ); + if ( + !!openFocusTrap && // If a focus trap is open somewhere on the page + !openFocusTrap.contains(e.target as Node) && // & the focus trap doesn't belong to this header + e.target !== actionsPopoverToggle // & we're not closing the actions popover toggle + ) { + // Trick the focus trap lib into registering an outside click - + // the drag/drop lib otherwise otherwise prevents the event 💀 + document.dispatchEvent(new MouseEvent('mousedown')); + } + setTimeout(() => { + setFocusedCell([index, -1], true); + }); + }, + [setFocusedCell, index, actionsPopoverToggle] + ); + + // Prevent any other keypresses when dragging + const isDraggingRef = useRef(false); + const handleOnKeydown: KeyboardEventHandler = useCallback((e) => { + if (isDraggingRef.current) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }, []); + + // UX polish: add a slight animation frame delay to the dragging ref end + // which prevents re-running the hover animation of column header actions + const updateDraggingRef = useCallback((isDragging: boolean) => { + // Only update if the state has changed from before + if (isDragging !== isDraggingRef.current) { + if (!isDragging) { + requestAnimationFrame(() => { + isDraggingRef.current = false; + }); + } else { + isDraggingRef.current = true; + } + } + }, []); + + return ( +
+ {columnResizer} + + {({ dragHandleProps }, { isDragging, mode }) => { + updateDraggingRef(isDragging); + + const { + role, // extracting role to not pass it along + tabIndex, // we want to use the columnheader rowing tabindex instead + ...restDragHandleProps + } = dragHandleProps ?? {}; + + const passedProps = { + ...restDragHandleProps, + css: reapplyCellStyles, + 'data-column-moving': isDraggingRef.current || undefined, + isDragging, + }; + + // since the cloned content is in a portal outside the datagrid + // we need to re-add styles to the cell as the scoped styles + // from the wrapper don't apply + const draggingStyles = [ + styles.euiDataGridHeaderCellDraggable, // ensure height is maintained while dragging + dataGridStyles.cellPadding[gridStyles.cellPadding!], + dataGridStyles.fontSize[gridStyles.fontSize!], + dataGridStyles.borders[gridStyles.border!], + mode === 'SNAP' && styles.isKeyboardDragging, + ]; + + return isDragging ? ( +
+ + {children(passedProps)} +
+ ) : ( + children(passedProps) + ); + }} +
+
+ ); + } +); +DraggableColumn.displayName = 'DraggableColumn'; + +/** + * Components for conditionally rendering drag/drop wrappers + * Allows us to conditionally call hooks while not instantiating a bunch + * of extra state/etc., since draggable columns isn't yet(?) a default + */ +type CanDragAndDropColumns = { + canDragAndDropColumns: boolean; +}; + +export const ConditionalDroppableColumns: FunctionComponent< + ComponentProps & CanDragAndDropColumns +> = memo(({ canDragAndDropColumns, children, ...rest }) => + canDragAndDropColumns ? ( + {children} + ) : ( + <>{children} + ) +); +ConditionalDroppableColumns.displayName = 'ConditionalDroppableColumns'; + +export const ConditionalDraggableColumn: FunctionComponent< + ComponentProps & CanDragAndDropColumns +> = memo(({ canDragAndDropColumns, children, ...rest }) => + canDragAndDropColumns ? ( + {children} + ) : ( + <>{children()} + ) +); +ConditionalDraggableColumn.displayName = 'ConditionalDraggableColumn'; + +/** + * Creates an invisible overlay that prevents hover interactions/transitions + * on other elements that the dragged element is dragged over, and also maintains + * the intended drag cursor over any location. + * + * TODO: If this is useful elsewhere, consider moving it to `src/services` + */ +export const DragOverlay: FunctionComponent<{ + isDragging?: boolean; + cursor?: CSSProperties['cursor']; + zIndex?: CSSProperties['zIndex']; +}> = memo(({ isDragging, cursor, zIndex = 9999 }) => { + return isDragging ? ( + +
+ + ) : null; +}); +DragOverlay.displayName = 'DragOverlay'; diff --git a/packages/eui/src/components/datagrid/data_grid.stories.tsx b/packages/eui/src/components/datagrid/data_grid.stories.tsx index b8bdd9bce64..114adef53a5 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.tsx @@ -9,6 +9,7 @@ import React, { useRef, useEffect } from 'react'; import type { Meta, StoryObj, ReactRenderer } from '@storybook/react'; import type { PlayFunctionContext } from '@storybook/csf'; +import { expect, fireEvent, waitFor } from '@storybook/test'; import { within } from '../../../.storybook/test'; import { enableFunctionToggleControls } from '../../../.storybook/utils'; @@ -132,6 +133,36 @@ export const CustomHeaderContent: Story = { render: (args: EuiDataGridProps) => , }; +export const DraggableColumns: Story = { + parameters: { + controls: { include: ['columns', 'columnVisibility'] }, + }, + args: { + ...defaultStorybookArgs, + columnVisibility: { + ...defaultStorybookArgs.columnVisibility, + canDragAndDropColumns: true, + }, + columns: defaultStorybookArgs.columns.map((column) => + column.id === 'location' + ? { ...column, display: } + : column + ), + }, + render: (args: EuiDataGridProps) => , + play: async ({ canvasElement }: PlayFunctionContext) => { + const canvas = within(canvasElement); + + await waitFor(async () => { + expect( + canvas.getByTestSubject('dataGridHeaderCell-name') + ).toBeInTheDocument(); + }); + + await fireEvent.focus(canvas.getByTestSubject('dataGridHeaderCell-name')); + }, +}; + /** * VRT only */ diff --git a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx index 5d5f8442e30..a30a30af04b 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx @@ -141,10 +141,6 @@ const columns = [ }, ], }, - { - id: 'location', - displayAsText: 'Location', - }, { id: 'account', displayAsText: 'Account', @@ -185,6 +181,10 @@ const columns = [ }, ], }, + { + id: 'location', + displayAsText: 'Location', + }, { id: 'date', displayAsText: 'Date', @@ -287,6 +287,7 @@ export const defaultStorybookArgs = { 'version', ], setVisibleColumns: () => {}, + canDragAndDropColumns: false, }, inMemory: { level: 'sorting' } as const, pagination: { @@ -390,7 +391,11 @@ export const StatefulDataGrid = (props: EuiDataGridProps) => { return ( { expect(getByTestSubject('display')).toBeInTheDocument(); expect(getByTitle('displayAsText')).toBeInTheDocument(); }); + + describe('canDragAndDropColumns', () => { + it('should render draggable header columns cells', () => { + const columnVisibility = { + visibleColumns: ['ColumnA', 'ColumnB'], + setVisibleColumns: () => {}, + canDragAndDropColumns: true, + }; + + const { getByTestSubject } = render( + + ); + + expect( + getByTestSubject('euiDataGridHeaderDroppable') + ).toBeInTheDocument(); + expect( + getByTestSubject('dataGridHeaderCell-ColumnA').parentElement + ).toHaveClass('euiDraggable'); + }); + }); }); describe('column sorting', () => { diff --git a/packages/eui/src/components/datagrid/data_grid.tsx b/packages/eui/src/components/datagrid/data_grid.tsx index 078fc580d42..1aa02617187 100644 --- a/packages/eui/src/components/datagrid/data_grid.tsx +++ b/packages/eui/src/components/datagrid/data_grid.tsx @@ -529,6 +529,9 @@ export const EuiDataGrid = memo( gridItemsRendered={gridItemsRendered} wrapperRef={contentRef} renderCustomGridBody={renderCustomGridBody} + canDragAndDropColumns={ + columnVisibility.canDragAndDropColumns + } />
diff --git a/packages/eui/src/components/datagrid/data_grid_types.ts b/packages/eui/src/components/datagrid/data_grid_types.ts index a2f459f6bbf..f92e673cbb7 100644 --- a/packages/eui/src/components/datagrid/data_grid_types.ts +++ b/packages/eui/src/components/datagrid/data_grid_types.ts @@ -18,6 +18,7 @@ import { Ref, Component, ComponentClass, + KeyboardEventHandler, } from 'react'; import { VariableSizeGridProps, @@ -144,6 +145,7 @@ export interface EuiDataGridHeaderRowPropsSpecificProps { setVisibleColumns: (columnId: string[]) => void; switchColumnPos: (colFromId: string, colToId: string) => void; gridStyles: EuiDataGridStyle; + canDragAndDropColumns?: boolean; } export type EuiDataGridHeaderRowProps = CommonProps & @@ -153,15 +155,16 @@ export type EuiDataGridHeaderRowProps = CommonProps & export interface EuiDataGridHeaderCellProps extends Omit< EuiDataGridHeaderRowPropsSpecificProps, - 'leadingControlColumns' | 'gridStyles' + 'leadingControlColumns' | 'trailingControlColumns' | 'visibleColCount' > { - column: EuiDataGridColumn; index: number; + column: EuiDataGridColumn; + isLastColumn: boolean; } export interface EuiDataGridControlHeaderCellProps { index: number; - visibleColCount: number; + isLastColumn: boolean; controlColumn: EuiDataGridControlColumn; } @@ -169,12 +172,13 @@ export interface EuiDataGridHeaderCellWrapperProps { children: ReactNode | ((renderFocusTrap: boolean) => ReactNode); id: string; index: number; - visibleColCount: number; + isLastColumn: boolean; width?: number | null; className?: string; 'aria-label'?: AriaAttributes['aria-label']; - hasActionsPopover?: boolean; - openActionsPopover?: () => void; + hasColumnActions?: boolean; + isDragging?: boolean; + onKeyDown?: KeyboardEventHandler; } export type EuiDataGridFooterRowProps = CommonProps & @@ -211,7 +215,7 @@ export type EuiDataGridFocusedCell = [number, number]; export interface DataGridFocusContextShape { focusedCell?: EuiDataGridFocusedCell; - setFocusedCell: (cell: EuiDataGridFocusedCell) => void; + setFocusedCell: (cell: EuiDataGridFocusedCell, forceUpdate?: boolean) => void; setIsFocusedCellInView: (isFocusedCellInView: boolean) => void; onFocusUpdate: ( cell: EuiDataGridFocusedCell, @@ -429,6 +433,7 @@ export interface EuiDataGridColumnResizerProps { columnId: string; columnWidth: number; setColumnWidth: (columnId: string, width: number) => void; + isLastColumn: boolean; } export interface EuiDataGridColumnResizerState { @@ -478,6 +483,7 @@ export interface EuiDataGridBodyProps { gridItemsRendered: MutableRefObject; wrapperRef: MutableRefObject; className?: string; + canDragAndDropColumns?: boolean; } export interface EuiDataGridCustomBodyProps { @@ -835,6 +841,9 @@ export interface EuiDataGridColumnVisibility { * A callback for when a column's visibility or order is modified by the user. */ setVisibleColumns: (visibleColumns: string[]) => void; + + /** Enables reordering grid columns on drag and drop via the headers cells */ + canDragAndDropColumns?: boolean; } export interface EuiDataGridColumnWidths { diff --git a/packages/eui/src/components/datagrid/utils/col_widths.test.ts b/packages/eui/src/components/datagrid/utils/col_widths.test.ts index 1e94bc6332b..c98741d3ce1 100644 --- a/packages/eui/src/components/datagrid/utils/col_widths.test.ts +++ b/packages/eui/src/components/datagrid/utils/col_widths.test.ts @@ -85,7 +85,7 @@ describe('doesColumnHaveAnInitialWidth', () => { describe('useColumnWidths', () => { const args = { leadingControlColumns: [{ id: 'a', width: 50 }] as any, - columns: [{ id: 'b', initialWidth: 75 }, { id: 'c ' }], + columns: [{ id: 'b', initialWidth: 75 }, { id: 'c' }], trailingControlColumns: [{ id: 'd', width: 25 }] as any, defaultColumnWidth: 150, onColumnResize: jest.fn(), @@ -99,16 +99,43 @@ describe('useColumnWidths', () => { expect(columnWidths).toEqual({ b: 75 }); }); - it('recomputes column widths on columns change', () => { - const { rerender, result } = renderHook(useColumnWidths, { - initialProps: args, + describe('when `columns` updates', () => { + it('adds new `initialWidth`s', () => { + const { rerender, result } = renderHook(useColumnWidths, { + initialProps: args, + }); + rerender({ + ...args, + columns: [{ id: 'f', initialWidth: 100 }], + }); + + expect(result.current.columnWidths).toEqual({ b: 75, f: 100 }); }); - rerender({ - ...args, - columns: [{ id: 'c', initialWidth: 125 }], + + it('does not remove column widths that have been hidden', () => { + const { rerender, result } = renderHook(useColumnWidths, { + initialProps: args, + }); + + rerender({ + ...args, + columns: [{ id: 'c' }], + }); + expect(result.current.columnWidths).toEqual({ b: 75 }); }); - expect(result.current.columnWidths).toEqual({ c: 125 }); + it('does not override column widths that have already been set by manual user resize', () => { + const { rerender, result } = renderHook(useColumnWidths, { + initialProps: args, + }); + + renderHookAct(() => result.current.setColumnWidth('b', 150)); + rerender({ + ...args, + columns: [...args.columns], + }); + expect(result.current.columnWidths).toEqual({ b: 150 }); + }); }); }); diff --git a/packages/eui/src/components/datagrid/utils/col_widths.ts b/packages/eui/src/components/datagrid/utils/col_widths.ts index 9a3be0017a0..e919151143e 100644 --- a/packages/eui/src/components/datagrid/utils/col_widths.ts +++ b/packages/eui/src/components/datagrid/utils/col_widths.ts @@ -79,22 +79,29 @@ export const useColumnWidths = ({ setColumnWidth: (columnId: string, width: number) => void; getColumnWidth: (index: number) => number; } => { - const computeColumnWidths = useCallback(() => { - return columns - .filter(doesColumnHaveAnInitialWidth) - .reduce((initialWidths, column) => { - return { ...initialWidths, [column.id]: column.initialWidth! }; - }, {}); - }, [columns]); + const getInitialWidths = useCallback( + (prevColumnWidths?: EuiDataGridColumnWidths) => { + const columnWidths = { ...prevColumnWidths }; + columns + .filter(doesColumnHaveAnInitialWidth) + .forEach(({ id, initialWidth }) => { + if (columnWidths[id] == null) { + columnWidths[id] = initialWidth!; + } + }); + return columnWidths; + }, + [columns] + ); // Passes initializer function for performance, so computing only runs once on init // @see https://react.dev/reference/react/useState#examples-initializer const [columnWidths, setColumnWidths] = - useState(computeColumnWidths); + useState(getInitialWidths); useUpdateEffect(() => { - setColumnWidths(computeColumnWidths()); - }, [computeColumnWidths]); + setColumnWidths(getInitialWidths); + }, [columns]); const setColumnWidth = useCallback( (columnId: string, width: number) => { @@ -102,9 +109,7 @@ export const useColumnWidths = ({ ...prevColumnWidths, [columnId]: width, })); - if (onColumnResize) { - onColumnResize({ columnId, width }); - } + onColumnResize?.({ columnId, width }); }, [onColumnResize] ); diff --git a/packages/eui/src/components/datagrid/utils/focus.ts b/packages/eui/src/components/datagrid/utils/focus.ts index 7f6ab32c27a..e20d4bfd9c9 100644 --- a/packages/eui/src/components/datagrid/utils/focus.ts +++ b/packages/eui/src/components/datagrid/utils/focus.ts @@ -61,11 +61,12 @@ export const useFocus = (): DataGridFocusContextShape & { >(undefined); const setFocusedCell = useCallback( - (nextFocusedCell: EuiDataGridFocusedCell) => { + (nextFocusedCell: EuiDataGridFocusedCell, forceUpdate?: boolean) => { _setFocusedCell((prevFocusedCell) => { // If the x/y coordinates remained the same, don't update. This keeps the focusedCell // reference stable, and allows it to be used in places that need reference equality. if ( + !forceUpdate && nextFocusedCell[0] === prevFocusedCell?.[0] && nextFocusedCell[1] === prevFocusedCell?.[1] ) { diff --git a/packages/eui/src/components/datagrid/utils/scrolling.test.tsx b/packages/eui/src/components/datagrid/utils/scrolling.test.tsx index 12b3c477003..ba182cba4fb 100644 --- a/packages/eui/src/components/datagrid/utils/scrolling.test.tsx +++ b/packages/eui/src/components/datagrid/utils/scrolling.test.tsx @@ -122,6 +122,35 @@ describe('useScrollCellIntoView', () => { scrollCellIntoView({ rowIndex: 1, colIndex: 5 }); expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 50, scrollTop: 0 }); }); + + it('correctly accounts for header cells inside EuiDraggable', () => { + const mockEuiDraggable = { + offsetLeft: 500, + }; + const cell = { + ...mockCell, + offsetLeft: 0, + offsetWidth: 100, + offsetParent: mockEuiDraggable, + }; + const grid = { + offsetWidth: 600, + clientWidth: 200, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = renderHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + canDragAndDropColumns: true, + }) + ).result.current; + + scrollCellIntoView({ rowIndex: -1, colIndex: 4 }); + // should scroll all the way to the right, aka the grid's offsetWidth - clientWidth + expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 400, scrollTop: 0 }); + }); }); describe('left scroll adjustments', () => { @@ -169,6 +198,34 @@ describe('useScrollCellIntoView', () => { scrollCellIntoView({ rowIndex: 1, colIndex: 1 }); expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 50, scrollTop: 0 }); }); + + it('correctly accounts for header cells inside EuiDraggable', () => { + const mockEuiDraggable = { + offsetLeft: 100, + }; + const cell = { + ...mockCell, + offsetLeft: 0, + offsetWidth: 100, + offsetParent: mockEuiDraggable, + }; + const grid = { + scrollLeft: 300, + }; + + getCell.mockReturnValue(cell); + const { scrollCellIntoView } = renderHook(() => + useScrollCellIntoView({ + ...args, + outerGridRef: { current: { ...args.outerGridRef.current, ...grid } }, + canDragAndDropColumns: true, + }) + ).result.current; + + scrollCellIntoView({ rowIndex: -1, colIndex: 1 }); + // should have been called with mockEuiDraggable's offsetLeft + expect(scrollTo).toHaveBeenCalledWith({ scrollLeft: 100, scrollTop: 0 }); + }); }); describe('bottom scroll adjustments', () => { diff --git a/packages/eui/src/components/datagrid/utils/scrolling.tsx b/packages/eui/src/components/datagrid/utils/scrolling.tsx index 4ad68ac1638..5280e2cbe66 100644 --- a/packages/eui/src/components/datagrid/utils/scrolling.tsx +++ b/packages/eui/src/components/datagrid/utils/scrolling.tsx @@ -35,6 +35,7 @@ interface Dependencies { footerRowHeight: number; visibleRowCount: number; hasStickyFooter: boolean; + canDragAndDropColumns?: boolean; } /** @@ -83,6 +84,7 @@ export const useScrollCellIntoView = ({ footerRowHeight, visibleRowCount, hasStickyFooter, + canDragAndDropColumns, }: Dependencies) => { const scrollCellIntoView = useCallback( // Note: in order for this UX to work correctly with react-window's APIs, @@ -116,6 +118,12 @@ export const useScrollCellIntoView = ({ } if (!cell) return; // If for some reason we can't find a valid cell, short-circuit + const isStickyHeader = rowIndex === -1; + const isStickyFooter = hasStickyFooter && rowIndex === visibleRowCount; + const isDraggableHeader = canDragAndDropColumns && isStickyHeader; + const isDraggableHeaderCell = + isDraggableHeader && cell.offsetLeft === 0 && colIndex !== 0; + // We now manually adjust scroll positioning around the cell to ensure it's // fully in view on all sides. A couple of notes on this: // 1. We're avoiding relying on react-window's scrollToItem for this because it also @@ -128,8 +136,14 @@ export const useScrollCellIntoView = ({ let adjustedScrollTop; let adjustedScrollLeft; + // Draggable header columns are nested within EuiDraggables, + // and their offsetLeft needs to go up a wrapper as a result + const cellLeftPos = isDraggableHeaderCell + ? (cell.offsetParent as HTMLDivElement).offsetLeft + : cell.offsetLeft; + // Check if the cell's right side is outside the current scrolling bounds - const cellRightPos = cell.offsetLeft + cell.offsetWidth; + const cellRightPos = cellLeftPos + cell.offsetWidth; const rightScrollBound = scrollLeft + outerGridRef.current.clientWidth; // Note: We specifically want clientWidth and not offsetWidth here to account for scrollbars const rightWidthOutOfView = cellRightPos - rightScrollBound; if (rightWidthOutOfView > 0) { @@ -137,7 +151,6 @@ export const useScrollCellIntoView = ({ } // Check if the cell's left side is outside the current scrolling bounds - const cellLeftPos = cell.offsetLeft; const leftScrollBound = adjustedScrollLeft ?? scrollLeft; const leftWidthOutOfView = leftScrollBound - cellLeftPos; if (leftWidthOutOfView > 0) { @@ -148,9 +161,6 @@ export const useScrollCellIntoView = ({ // Skip top/bottom scroll adjustments for sticky headers & footers // since they should always be in view vertically - const isStickyHeader = rowIndex === -1; - const isStickyFooter = hasStickyFooter && rowIndex === visibleRowCount; - if (!isStickyHeader && !isStickyFooter) { const parentRow = cell.parentNode as HTMLDivElement; @@ -191,6 +201,7 @@ export const useScrollCellIntoView = ({ footerRowHeight, visibleRowCount, hasStickyFooter, + canDragAndDropColumns, ] ); diff --git a/packages/eui/src/components/drag_and_drop/draggable.tsx b/packages/eui/src/components/drag_and_drop/draggable.tsx index 1a45309cd3c..2d74b3435e0 100644 --- a/packages/eui/src/components/drag_and_drop/draggable.tsx +++ b/packages/eui/src/components/drag_and_drop/draggable.tsx @@ -31,9 +31,12 @@ export interface EuiDraggableProps children: ReactElement | DraggableProps['children']; className?: string; /** - * Whether the `children` will provide and set up its own drag handle + * Whether the `children` will provide and set up its own drag handle. + * The `custom` value additionally removes the `role` from the draggable container. + * Use this if the `children` element is focusable and should keep its + * semantic role for accessibility purposes. */ - customDragHandle?: boolean; + customDragHandle?: boolean | 'custom'; /** * Whether the container has interactive children and should have `role="group"` instead of `"button"`. * Setting this flag ensures your drag & drop container is keyboard and screen reader accessible. @@ -79,6 +82,8 @@ export const EuiDraggable: FunctionComponent = ({ const euiTheme = useEuiTheme(); const styles = euiDraggableStyles(euiTheme); + const hasCustomDragHandle = customDragHandle !== false; + return ( = ({ > {(provided, snapshot, rubric) => { const { isDragging } = snapshot; + const isFullyCustomDragHandle = customDragHandle === 'custom'; const cssStyles = [ styles.euiDraggable, @@ -110,7 +116,7 @@ export const EuiDraggable: FunctionComponent = ({ <>
= ({ // interactive element. Screen readers will cue users that this is a container // and has one or more elements inside that are part of a related group. role={ - hasInteractiveChildren + isFullyCustomDragHandle + ? undefined // prevent wrapper role from removing semantics of the children + : hasInteractiveChildren ? 'group' : provided.dragHandleProps?.role } // If the container includes an interactive element, we remove the tabindex=0 // because [role="group"] does not permit or warrant a tab stop + // additionally we remove the tabindex when the child is a fully custom handle + // that has its own tabindex and handle props tabIndex={ - hasInteractiveChildren + hasInteractiveChildren || isFullyCustomDragHandle ? undefined : provided.dragHandleProps?.tabIndex }