From 2bed61958c11bf1353f63b5939631cb7ec3e9e12 Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Tue, 14 Apr 2026 14:27:35 +0100 Subject: [PATCH 1/3] refactor(flat-table): moves tab stop computation to parent and set it synchronously in children --- .../__internal__/strict-flat-table.context.ts | 6 +++-- .../flat-table/__internal__/use-table-cell.ts | 15 +++-------- .../flat-table-row.component.tsx | 27 ++++++++++++++----- .../flat-table-row/flat-table-row.test.tsx | 8 ++++-- .../flat-table/flat-table.component.tsx | 24 ++++++++++------- 5 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/components/flat-table/__internal__/strict-flat-table.context.ts b/src/components/flat-table/__internal__/strict-flat-table.context.ts index d4eb5b34f8..d8eaba0db2 100644 --- a/src/components/flat-table/__internal__/strict-flat-table.context.ts +++ b/src/components/flat-table/__internal__/strict-flat-table.context.ts @@ -3,7 +3,8 @@ import { FlatTableProps } from "../flat-table.component"; export interface StrictFlatTableContextType extends Pick { - getTabStopElementId: () => string; + tabStopElementId: string; + notifyTabStopChange: () => void; } const [StrictFlatTableProvider, useStrictFlatTableContext] = @@ -12,7 +13,8 @@ const [StrictFlatTableProvider, useStrictFlatTableContext] = errorMessage: "Carbon FlatTable: Context not found. Have you wrapped your Carbon subcomponents properly? See stack trace for more details.", defaultValue: { - getTabStopElementId: () => "", + tabStopElementId: "", + notifyTabStopChange: () => {}, }, }); diff --git a/src/components/flat-table/__internal__/use-table-cell.ts b/src/components/flat-table/__internal__/use-table-cell.ts index f41d922ee5..228a33663e 100644 --- a/src/components/flat-table/__internal__/use-table-cell.ts +++ b/src/components/flat-table/__internal__/use-table-cell.ts @@ -1,10 +1,9 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import FlatTableRowContext from "../flat-table-row/__internal__/flat-table-row.context"; import { useStrictFlatTableContext } from "./strict-flat-table.context"; export default (id: string) => { - const { getTabStopElementId } = useStrictFlatTableContext(); - const [tabIndex, setTabIndex] = useState(-1); + const { tabStopElementId } = useStrictFlatTableContext(); const { expandable, firstCellId, @@ -85,15 +84,7 @@ export default (id: string) => { } }; - useEffect(() => { - const tabstopTimer = setTimeout(() => { - setTabIndex(isExpandableCell && getTabStopElementId() === id ? 0 : -1); - }, 0); - - return () => { - clearTimeout(tabstopTimer); - }; - }, [getTabStopElementId, isExpandableCell, id]); + const tabIndex = isExpandableCell && tabStopElementId === id ? 0 : -1; return { expandable, diff --git a/src/components/flat-table/flat-table-row/flat-table-row.component.tsx b/src/components/flat-table/flat-table-row/flat-table-row.component.tsx index c7ed2a4438..46c9b5382b 100644 --- a/src/components/flat-table/flat-table-row/flat-table-row.component.tsx +++ b/src/components/flat-table/flat-table-row/flat-table-row.component.tsx @@ -93,8 +93,6 @@ export const FlatTableRow = React.forwardRef< const [rhsRowHeaderIndex, setRhsRowHeaderIndex] = useState(-1); const [firstCellId, setFirstCellId] = useState(null); const [cellsArray, setCellsArray] = useState([]); - const [tabIndex, setTabIndex] = useState(-1); - let interactiveRowProps = {}; useLayoutEffect(() => { @@ -178,8 +176,27 @@ export const FlatTableRow = React.forwardRef< `Do not render a right hand side \`${FlatTableRowHeader.displayName}\` before left hand side \`${FlatTableRowHeader.displayName}\``, ); - const { colorTheme, size, getTabStopElementId } = + const { colorTheme, size, tabStopElementId, notifyTabStopChange } = useStrictFlatTableContext(); + const tabIndex = tabStopElementId === internalId.current ? 0 : -1; + + // Notify FlatTable when this row's focusability or selection state changes + useEffect(() => { + if (onClick || expandable) { + notifyTabStopChange(); + } + return () => { + notifyTabStopChange(); + }; + }, [ + onClick, + expandable, + selected, + highlighted, + firstCellId, + notifyTabStopChange, + ]); + const { isInSidebar } = useContext(DrawerSidebarContext); const { stickyOffsets } = useContext(FlatTableHeadContext); @@ -244,10 +261,6 @@ export const FlatTableRow = React.forwardRef< setIsExpanded(expanded); }, [expanded]); - useEffect(() => { - setTabIndex(getTabStopElementId() === internalId.current ? 0 : -1); - }, [getTabStopElementId]); - const { isSubRow, firstRowId, addRow, removeRow } = useContext(SubRowContext); diff --git a/src/components/flat-table/flat-table-row/flat-table-row.test.tsx b/src/components/flat-table/flat-table-row/flat-table-row.test.tsx index 4298091486..cfe05dac4f 100644 --- a/src/components/flat-table/flat-table-row/flat-table-row.test.tsx +++ b/src/components/flat-table/flat-table-row/flat-table-row.test.tsx @@ -600,7 +600,7 @@ describe("when the row is `expandable`", () => { const cell = screen.getByRole("cell", { name: "cell1" }); expect(row).not.toHaveAttribute("tabindex"); - expect(cell).toHaveAttribute("tabindex", "-1"); + expect(cell).toHaveAttribute("tabindex", "0"); }); it("should add and apply the expected styling to the chevron icon", () => { @@ -1570,7 +1570,11 @@ describe("when the row is `expandable`", () => { render( "" }} + value={{ + size: "compact", + tabStopElementId: "", + notifyTabStopChange: () => {}, + }} > { + const [tabStopElementId, setTabStopElementId] = useState(""); + const [tabStopTrigger, setTabStopTrigger] = useState(0); + + const notifyTabStopChange = useCallback(() => { + setTabStopTrigger((v) => v + 1); + }, []); + + useEffect(() => { const focusableElements = Array.from( tableRef.current?.querySelectorAll(FOCUSABLE_ROW_AND_CELL_QUERY) || /* istanbul ignore next */ [], ); - - // if no other row is selected/ highlighted, we need to make the first row/ cell a tab stop const focusableElement = focusableElements.find( (el) => el.getAttribute("data-selected") === "true" || el.getAttribute("data-highlighted") === "true", ) || focusableElements[0]; - const currentlySelectedId = focusableElement?.getAttribute("id") || ""; - - return currentlySelectedId; - }, []); + const id = focusableElement?.getAttribute("id") || ""; + setTabStopElementId((prev) => (prev !== id ? id : prev)); + }, [tabStopTrigger]); const strictFlatTableValue = useMemo( () => ({ colorTheme, size, - getTabStopElementId, + tabStopElementId, + notifyTabStopChange, }), - [colorTheme, size, getTabStopElementId], + [colorTheme, size, tabStopElementId, notifyTabStopChange], ); const flatTableValue = useMemo( From bb84314dd3853738a36ab6818876e306bdd7b74c Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Tue, 14 Apr 2026 15:18:38 +0100 Subject: [PATCH 2/3] refactor(flat-table-row): consolidate state updates into reducer --- .../__internal__/row-layout-reducer.tsx | 57 +++++++++++ .../flat-table-row.component.tsx | 96 ++++--------------- 2 files changed, 78 insertions(+), 75 deletions(-) create mode 100644 src/components/flat-table/flat-table-row/__internal__/row-layout-reducer.tsx diff --git a/src/components/flat-table/flat-table-row/__internal__/row-layout-reducer.tsx b/src/components/flat-table/flat-table-row/__internal__/row-layout-reducer.tsx new file mode 100644 index 0000000000..fa102256a1 --- /dev/null +++ b/src/components/flat-table/flat-table-row/__internal__/row-layout-reducer.tsx @@ -0,0 +1,57 @@ +import { buildPositionMap } from "../../__internal__"; + +interface RowLayoutState { + leftPositions: Record; + rightPositions: Record; + firstCellIndex: number; + lhsRowHeaderIndex: number; + rhsRowHeaderIndex: number; + firstCellId: string | null; + cellsArray: Element[]; +} + +export const initialRowLayoutState: RowLayoutState = { + leftPositions: {}, + rightPositions: {}, + firstCellIndex: 0, + lhsRowHeaderIndex: -1, + rhsRowHeaderIndex: -1, + firstCellId: null, + cellsArray: [], +}; + +export const rowLayoutReducer = ( + state: RowLayoutState, + cells: HTMLTableCellElement[], +): RowLayoutState => { + const firstIndex = cells.findIndex( + (cell) => cell.getAttribute("data-component") !== "flat-table-checkbox", + ); + const lhsIndex = cells.findIndex( + (cell) => cell.getAttribute("data-sticky-align") === "left", + ); + const rhsIndex = cells.findIndex( + (cell) => cell.getAttribute("data-sticky-align") === "right", + ); + const { leftPositions, rightPositions, firstCellId } = state; + + return { + cellsArray: cells, + firstCellIndex: firstIndex !== -1 ? firstIndex : 0, + firstCellId: + firstIndex !== -1 ? cells[firstIndex].getAttribute("id") : firstCellId, + lhsRowHeaderIndex: lhsIndex, + rhsRowHeaderIndex: rhsIndex, + leftPositions: + lhsIndex !== -1 + ? buildPositionMap(cells.slice(0, lhsIndex + 1), "offsetWidth") + : leftPositions, + rightPositions: + rhsIndex !== -1 + ? buildPositionMap( + cells.slice(rhsIndex, cells.length).reverse(), + "offsetWidth", + ) + : rightPositions, + }; +}; diff --git a/src/components/flat-table/flat-table-row/flat-table-row.component.tsx b/src/components/flat-table/flat-table-row/flat-table-row.component.tsx index 46c9b5382b..c34bd13268 100644 --- a/src/components/flat-table/flat-table-row/flat-table-row.component.tsx +++ b/src/components/flat-table/flat-table-row/flat-table-row.component.tsx @@ -6,6 +6,7 @@ import React, { useState, useLayoutEffect, useCallback, + useReducer, } from "react"; import invariant from "invariant"; @@ -19,9 +20,12 @@ import { useStrictFlatTableContext } from "../__internal__/strict-flat-table.con import guid from "../../../__internal__/utils/helpers/guid"; import FlatTableRowContext from "./__internal__/flat-table-row.context"; import SubRowProvider, { SubRowContext } from "./__internal__/sub-row-provider"; -import { buildPositionMap } from "../__internal__"; import FlatTableHeadContext from "../flat-table-head/__internal__/flat-table-head.context"; import { useSortableRow } from "../__internal__/sortable"; +import { + rowLayoutReducer, + initialRowLayoutState, +} from "./__internal__/row-layout-reducer"; export interface FlatTableRowProps extends TagProps { /** Overrides default cell color, provide design token, any color from palette or any valid css color value. */ @@ -82,86 +86,28 @@ export const FlatTableRow = React.forwardRef< const [isExpanded, setIsExpanded] = useState(expanded); const rowRef = useRef(null); const firstColumnExpandable = expandableArea === "firstColumn"; - const [leftPositions, setLeftPositions] = useState>( - {}, - ); - const [rightPositions, setRightPositions] = useState< - Record - >({}); - const [firstCellIndex, setFirstCellIndex] = useState(0); - const [lhsRowHeaderIndex, setLhsRowHeaderIndex] = useState(-1); - const [rhsRowHeaderIndex, setRhsRowHeaderIndex] = useState(-1); - const [firstCellId, setFirstCellId] = useState(null); - const [cellsArray, setCellsArray] = useState([]); let interactiveRowProps = {}; - useLayoutEffect(() => { - const checkForPositionUpdates = ( - updated: Record, - current: Record, - ) => { - const updatedKeys = Object.keys(updated); - const currentKeys = Object.keys(current); - if (updatedKeys.length !== currentKeys.length) { - return true; - } - - return updatedKeys.some((key) => updated[key] !== current[key]); - }; + const [layoutState, dispatchLayout] = useReducer( + rowLayoutReducer, + initialRowLayoutState, + ); + const { + leftPositions, + rightPositions, + firstCellIndex, + lhsRowHeaderIndex, + rhsRowHeaderIndex, + firstCellId, + cellsArray, + } = layoutState; + useLayoutEffect(() => { const cells = rowRef.current?.querySelectorAll("th, td") as | NodeListOf | undefined; - - const cellArray = Array.from(cells || /*istanbul ignore next */ []); - setCellsArray(cellArray); - - const firstIndex = cellArray.findIndex( - (cell) => cell.getAttribute("data-component") !== "flat-table-checkbox", - ); - const lhsIndex = cellArray.findIndex( - (cell) => cell.getAttribute("data-sticky-align") === "left", - ); - const rhsIndex = cellArray.findIndex( - (cell) => cell.getAttribute("data-sticky-align") === "right", - ); - - setLhsRowHeaderIndex(lhsIndex); - setRhsRowHeaderIndex(rhsIndex); - - if (firstIndex !== -1) { - setFirstCellIndex(firstIndex); - setFirstCellId(cellArray[firstIndex].getAttribute("id")); - } else { - setFirstCellIndex(0); - } - if (lhsIndex !== -1) { - const updatedLeftPositions = buildPositionMap( - cellArray.slice(0, lhsRowHeaderIndex + 1), - "offsetWidth", - ); - - if (checkForPositionUpdates(updatedLeftPositions, leftPositions)) { - setLeftPositions(updatedLeftPositions); - } - } - if (rhsIndex !== -1) { - const updatedRightPositions = buildPositionMap( - cellArray.slice(rhsRowHeaderIndex, cellArray.length).reverse(), - "offsetWidth", - ); - - if (checkForPositionUpdates(updatedRightPositions, rightPositions)) { - setRightPositions(updatedRightPositions); - } - } - }, [ - children, - leftPositions, - lhsRowHeaderIndex, - rhsRowHeaderIndex, - rightPositions, - ]); + dispatchLayout(Array.from(cells || /* istanbul ignore next */ [])); + }, [children]); const noStickyColumnsOverlap = useMemo(() => { const hasLhsColumn = lhsRowHeaderIndex !== -1; From 34c90e6dc73eff5f1b3b183c325e87c570221bcd Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Tue, 14 Apr 2026 15:43:39 +0100 Subject: [PATCH 3/3] refactor(flat-table-row): remove state from sub-row provider --- .../__internal__/sub-row-provider.tsx | 15 ++++++++++----- .../flat-table-row/flat-table-row.style.ts | 7 ++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/flat-table/flat-table-row/__internal__/sub-row-provider.tsx b/src/components/flat-table/flat-table-row/__internal__/sub-row-provider.tsx index b5cd04eda3..ca30b5e4a8 100644 --- a/src/components/flat-table/flat-table-row/__internal__/sub-row-provider.tsx +++ b/src/components/flat-table/flat-table-row/__internal__/sub-row-provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useState } from "react"; +import React, { createContext, useCallback, useRef } from "react"; export interface SubRowContextProps { isSubRow?: boolean; @@ -15,19 +15,24 @@ export const SubRowContext = createContext({ }); const SubRowProvider = ({ children }: { children: React.ReactNode }) => { - const [rowIds, setRowIds] = useState([]); + const rowIds = useRef([]); const addRow = useCallback((id: string) => { - setRowIds((p) => [...p, id]); + rowIds.current = [...rowIds.current, id]; }, []); const removeRow = useCallback((id: string) => { - setRowIds((p) => p.filter((rowId) => rowId !== id)); + rowIds.current = rowIds.current.filter((rowId) => rowId !== id); }, []); return ( {children} diff --git a/src/components/flat-table/flat-table-row/flat-table-row.style.ts b/src/components/flat-table/flat-table-row/flat-table-row.style.ts index 0012dfaaa0..23098b81a6 100644 --- a/src/components/flat-table/flat-table-row/flat-table-row.style.ts +++ b/src/components/flat-table/flat-table-row/flat-table-row.style.ts @@ -128,6 +128,9 @@ interface StyledFlatTableRowProps rowHeight?: number; } +const nav = getNavigator(); +const isSafariBrowser = nav ? isSafari(nav) : /* istanbul ignore next */ false; + const StyledFlatTableRow = styled.tr.attrs( applyBaseTheme, )` @@ -156,7 +159,6 @@ const StyledFlatTableRow = styled.tr.attrs( draggable, rowHeight, }) => { - const nav = getNavigator(); const backgroundColor = bgColor ? toColor(theme, bgColor) : undefined; const customBorderColor = horizontalBorderColor ? toColor(theme, horizontalBorderColor) @@ -283,8 +285,7 @@ const StyledFlatTableRow = styled.tr.attrs( /* Styling for safari. Position relative does not work on tr elements on Safari */ // FIXME: this can cause hydration mismatches during SSR. - ${nav && - isSafari(nav) && + ${isSafariBrowser && css` position: -webkit-sticky; :after {