diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx
index 0ceab668bee..07ca7058706 100644
--- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx
@@ -35,4 +35,25 @@ const tableHooks = [useAnnounceEmptyCells]; // this array should be memoized
```
+## Disabling a Plugin Hook
+
+Every plugin hook accepts a `disabled` option (default `false`). When `disabled` is `true`, the hook keeps its slot in the `tableHooks` array but does nothing.
+
+React's [rules of hooks](https://react.dev/reference/rules/rules-of-hooks) forbid adding or removing `tableHooks` entries at runtime, so use `disabled` to switch a hook off instead. To toggle it while the table is mounted, re-create the hook **at the same position** in the (memoized) `tableHooks` array with the new `disabled` value — never add, remove, or reorder entries, and keep `disabled` in the array's dependencies.
+
+The two hooks that are usually passed by reference (`useAnnounceEmptyCells`, `useF2CellEdit`) also accept the option when called as a factory. The call form depends on the hook's other parameters:
+
+```jsx
+const tableHooks = useMemo(
+ () => [
+ useAnnounceEmptyCells({ disabled }), // factory form of a by-reference hook
+ useStickyColumns({ disabled }), // options object
+ useManualRowSelect('isSelected', { disabled }), // positional arg + options
+ ],
+ [disabled],
+);
+
+;
+```
+
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginAnnounceEmptyCells.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginAnnounceEmptyCells.mdx
index 65918403ae0..7bc0ca6403e 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginAnnounceEmptyCells.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginAnnounceEmptyCells.mdx
@@ -13,6 +13,8 @@ The `useAnnounceEmptyCells` plugin hook adds screen reader announcements for emp
**Note:** Some screen readers (depending on their configuration) automatically detect empty cells, potentially resulting in duplicate announcements of empty cells.
+**Disabling:** Call it as a factory with `disabled: true` (`useAnnounceEmptyCells({ disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginDisableRowSelection.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginDisableRowSelection.mdx
index 5c3cf3c60ad..acc02110151 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginDisableRowSelection.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginDisableRowSelection.mdx
@@ -27,6 +27,8 @@ With the `useRowDisableSelection` it's possible to disable the selection of spec
**Note:** Using this hook in combination with the `useIndeterminateRowSelection` hook is not supported!
+**Disabling:** Pass `disabled: true` as the second argument (`useRowDisableSelection(disableRowFunc, { disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx
index 9ff8f84391a..d9f385ed73f 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginF2CellEdit.mdx
@@ -26,6 +26,8 @@ The hook manages focus, keyboard navigation, and `tabindex` for cells with inter
- Updates the cell's `aria-label` with the interactive element's name for accessibility.
- Prevents standard navigation keys from interfering when editing a cell.
+**Disabling:** Call it as a factory with `disabled: true` (`useF2CellEdit({ disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginIndeterminateRowSelection.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginIndeterminateRowSelection.mdx
index 5c12ed6136b..ccd27962a3a 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginIndeterminateRowSelection.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginIndeterminateRowSelection.mdx
@@ -56,4 +56,6 @@ The plugin hook allows passing a callback as parameter with the following struct
The callback is fired, every time the internal `indeterminateRows` state is changed.
The event parameter is an object, with all indeterminate rows by id (e.g. `{"0.1":true}`) and the table instance.
+**Disabling:** Pass `disabled: true` as the second argument (`useIndeterminateRowSelection(onIndeterminateChange, { disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginManualRowSelect.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginManualRowSelect.mdx
index 79907613ad4..019433bf0ca 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginManualRowSelect.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginManualRowSelect.mdx
@@ -14,6 +14,8 @@ If this key is found on the original data row, and it is `true`, this row will b
**Note:** This hook sets `autoResetSelectedRows` to `false` by default unless explicitly defined via `reactTableOptions`. [Learn more](?path=/docs/data-display-analyticaltable-faq--docs#how-to-stop-the-table-state-from-automatically-resetting-when-the-data-changes)
+**Disabling:** Pass `disabled: true` as the second argument (`useManualRowSelect('isSelected', { disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginOnColumnResize.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginOnColumnResize.mdx
index b9c4af3f366..0f464878a21 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginOnColumnResize.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginOnColumnResize.mdx
@@ -16,6 +16,7 @@ The first parameter is the callback itself (`e: { columnWidth: number; header: R
- `liveUpdate`: If `liveUpdate` is `true`, the resize function will fire every time the width has changed depending on the `options.wait` delay.
- `wait`: The number of milliseconds for which the calls are to be delayed. **Defaults to `100`**. (Only has an effect if `liveUpdate` is `true`)
+- `disabled`: If `true`, the hook does nothing while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginOrderedMultiSort.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginOrderedMultiSort.mdx
index cac92e0a00e..27fa913a559 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginOrderedMultiSort.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginOrderedMultiSort.mdx
@@ -17,6 +17,8 @@ It receives one parameter that defines the sorting priority by the column id.
**Note:** Column IDs that are not found in the array use the default priority, so the first sorted column has a higher priority than the next sorted column.
+**Disabling:** Pass `disabled: true` as the second argument (`useOrderedMultiSort(orderedIds, { disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx
index c5ffe163414..188e37ada7a 100644
--- a/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx
+++ b/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx
@@ -44,6 +44,8 @@ The hook accepts an options object with two optional callbacks:
- `enabled`: `true` if sticky rendering is now active (columns fit), `false` if auto-disabled.
- `stickyColumns`: the current user-frozen column ids (unchanged by the auto-toggle).
+**Disabling:** Pass `disabled: true` (`useStickyColumns({ disabled: true })`) to turn the hook off while keeping it in `tableHooks`. See [Disabling a Plugin Hook](?path=/docs/data-display-analyticaltable-plugin-hooks--docs#disabling-a-plugin-hook).
+
## Example
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useAnnounceEmptyCells.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useAnnounceEmptyCells.ts
index dece8508f2c..578d147131e 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useAnnounceEmptyCells.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useAnnounceEmptyCells.ts
@@ -1,12 +1,18 @@
import { useCallback } from 'react';
-import type { CellType, ReactTableHooks, TableInstance } from '../types/index.js';
+import type {
+ AnalyticalTablePluginHookOptions,
+ CellType,
+ PluginHook,
+ ReactTableHooks,
+ TableInstance,
+} from '../types/index.js';
-/**
- * The `useAnnounceEmptyCells` plugin hook adds screen reader announcements for empty cells.
- *
- * **Note:** Some screen readers (depending on their configuration) automatically detect empty cells, potentially resulting in duplicate announcements of empty cells.
- */
-export const useAnnounceEmptyCells = (hooks: ReactTableHooks) => {
+// Distinguishes legacy direct usage (`[useAnnounceEmptyCells]`, react-table passes the hooks registry)
+// from factory usage (`[useAnnounceEmptyCells({ disabled })]`). The registry always exposes array props.
+const isHooksRegistry = (arg: unknown): arg is ReactTableHooks =>
+ !!arg && Array.isArray((arg as ReactTableHooks).getCellProps);
+
+const useAnnounceEmptyCellsPlugin = (hooks: ReactTableHooks, disabled: boolean) => {
const setCellProps = useCallback(
(
cellProps,
@@ -27,6 +33,42 @@ export const useAnnounceEmptyCells = (hooks: ReactTableHooks) => {
[],
);
+ if (disabled) {
+ return;
+ }
hooks.getCellProps.push(setCellProps);
};
+
+export interface UseAnnounceEmptyCellsHook {
+ /** Legacy direct usage: `tableHooks={[useAnnounceEmptyCells]}`. */
+ (hooks: ReactTableHooks): void;
+ /** Factory usage: `tableHooks={[useAnnounceEmptyCells({ disabled })]}`. */
+ (options?: AnalyticalTablePluginHookOptions): PluginHook;
+ pluginName: string;
+}
+
+/**
+ * The `useAnnounceEmptyCells` plugin hook adds screen reader announcements for empty cells.
+ *
+ * **Note:** Some screen readers (depending on their configuration) automatically detect empty cells, potentially resulting in duplicate announcements of empty cells.
+ *
+ * Can be used either directly (`tableHooks={[useAnnounceEmptyCells]}`) or as a factory to pass options
+ * (`tableHooks={[useAnnounceEmptyCells({ disabled })]}`).
+ *
+ * @param {object} [options] - Additional options.
+ * @param {boolean} [options.disabled=false] - If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
+ */
+export const useAnnounceEmptyCells = ((hooksOrOptions?: ReactTableHooks | AnalyticalTablePluginHookOptions) => {
+ if (isHooksRegistry(hooksOrOptions)) {
+ useAnnounceEmptyCellsPlugin(hooksOrOptions, false);
+ return;
+ }
+ const { disabled = false } = hooksOrOptions ?? {};
+ // The returned `plugin` is pushed into `tableHooks` and invoked by react-table as a hook; threading
+ // `disabled` through this arrow is not a conditional hook call.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const plugin = (hooks: ReactTableHooks) => useAnnounceEmptyCellsPlugin(hooks, disabled);
+ plugin.pluginName = 'useAnnounceEmptyCells';
+ return plugin;
+}) as UseAnnounceEmptyCellsHook;
useAnnounceEmptyCells.pluginName = 'useAnnounceEmptyCells';
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts
index a8eb0404936..1d0741f33ac 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useF2CellEdit.ts
@@ -3,7 +3,15 @@ import type { Ui5DomRef } from '@ui5/webcomponents-react-base';
import type { FocusEventHandler, KeyboardEventHandler } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { INCLUDES_X, MOVE_TO_CONTENT_F2 } from '../../../i18n/i18n-defaults.js';
-import type { CellInstance, CellType, ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
+import type {
+ AnalyticalTablePluginHookOptions,
+ CellInstance,
+ CellType,
+ ColumnType,
+ PluginHook,
+ ReactTableHooks,
+ TableInstance,
+} from '../types/index.js';
import { NAVIGATION_KEYS } from '../util/index.js';
const NON_STANDARD_INTERACTIVE_ELEMENTS = [
@@ -21,53 +29,7 @@ const NON_STANDARD_INTERACTIVE_ELEMENTS = [
// Frames to wait for a component's focus DOM ref to become resolvable (nested shadow roots may render late).
const MAX_FOCUS_REF_RETRIES = 3;
-/**
- * A plugin hook that enables F2-based cell editing for interactive elements inside a cell.
- *
- * To __ensure the hook works correctly__, make sure that:
- *
- * - Each column containing interactive elements has the `interactiveElementName` property set. __Note:__ This property is also used to describe the cell's content for screen readers.
- * - The callback Ref returned by `useF2CellEdit.useCallbackRef` is attached to every interactive element within the cell.
- *
- * It manages focus, keyboard navigation, and `tabindex` for cells with interactive content:
- * - Pressing `F2` moves focus between the cell container and its first interactive element.
- * - Pressing `Tab` on a focused header cell moves focus to the body cell in the same column at the last focused body row (or the first row if none was focused).
- * - Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column.
- * - Updates the cell's `aria-label` with the interactive element's name for accessibility.
- * - Prevents standard navigation keys from interfering when editing a cell.
- *
- * @example
- * ```tsx
- * import type {
- * AnalyticalTableCellInstance,
- * AnalyticalTableColumnDefinition,
- * InputDomRef,
- * AnalyticalTablePropTypes,
- * } from '@ui5/webcomponents-react';
- * import { AnalyticalTableHooks, AnalyticalTable, Input } from '@ui5/webcomponents-react';
- *
- * const columns: AnalyticalTableColumnDefinition[] = [
- * {
- * Header: 'Input',
- * id: 'input',
- * Cell: (props: AnalyticalTableCellInstance) => {
- * const callbackRef = AnalyticalTableHooks.useF2CellEdit.useCallbackRef(props);
- * return ;
- * },
- * interactiveElementName: 'Input',
- * },
- * ];
- *
- * const tableHooks: AnalyticalTablePropTypes['tableHooks'] = [AnalyticalTableHooks.useF2CellEdit];
- *
- * function TableWithInput() {
- * return ;
- * }
- * ```
- *
- * @since 2.14.0
- */
-export const useF2CellEdit = (hooks: ReactTableHooks) => {
+const useF2CellEditPlugin = (hooks: ReactTableHooks, disabled: boolean) => {
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
const lastFocusedBodyRowRef = useRef(null);
@@ -191,12 +153,97 @@ export const useF2CellEdit = (hooks: ReactTableHooks) => {
[i18nBundle],
);
+ // `useInstanceBeforeDimensions` is called as a React hook by react-table, so it must always be
+ // registered (stable hook count); it no-ops internally when disabled.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ hooks.useInstanceBeforeDimensions.push((instance: TableInstance) => useInstanceBeforeDimensions(instance, disabled));
+ if (disabled) {
+ return;
+ }
hooks.getTableProps.push(setTableProps);
hooks.getCellProps.push(setCellProps);
hooks.getHeaderProps.push(setHeaderProps);
hooks.stateReducers.push(stateReducer);
- hooks.useInstanceBeforeDimensions.push(useInstanceBeforeDimensions);
};
+
+export interface UseF2CellEditHook {
+ /** Legacy direct usage: `tableHooks={[useF2CellEdit]}`. */
+ (hooks: ReactTableHooks): void;
+ /** Factory usage: `tableHooks={[useF2CellEdit({ disabled })]}`. */
+ (options?: AnalyticalTablePluginHookOptions): PluginHook;
+ pluginName: string;
+ useCallbackRef: (props: CellInstance) => (node: T | null) => void;
+}
+
+// Distinguishes legacy direct usage (`[useF2CellEdit]`, react-table passes the hooks registry) from
+// factory usage (`[useF2CellEdit({ disabled })]`). The registry always exposes array props.
+const isHooksRegistry = (arg: unknown): arg is ReactTableHooks =>
+ !!arg && Array.isArray((arg as ReactTableHooks).getCellProps);
+
+/**
+ * A plugin hook that enables F2-based cell editing for interactive elements inside a cell.
+ *
+ * To __ensure the hook works correctly__, make sure that:
+ *
+ * - Each column containing interactive elements has the `interactiveElementName` property set. __Note:__ This property is also used to describe the cell's content for screen readers.
+ * - The callback Ref returned by `useF2CellEdit.useCallbackRef` is attached to every interactive element within the cell.
+ *
+ * It manages focus, keyboard navigation, and `tabindex` for cells with interactive content:
+ * - Pressing `F2` moves focus between the cell container and its first interactive element.
+ * - Pressing `Tab` on a focused header cell moves focus to the body cell in the same column at the last focused body row (or the first row if none was focused).
+ * - Pressing `Shift+Tab` on a focused body cell moves focus back to the header cell of the same column.
+ * - Updates the cell's `aria-label` with the interactive element's name for accessibility.
+ * - Prevents standard navigation keys from interfering when editing a cell.
+ *
+ * @example
+ * ```tsx
+ * import type {
+ * AnalyticalTableCellInstance,
+ * AnalyticalTableColumnDefinition,
+ * InputDomRef,
+ * AnalyticalTablePropTypes,
+ * } from '@ui5/webcomponents-react';
+ * import { AnalyticalTableHooks, AnalyticalTable, Input } from '@ui5/webcomponents-react';
+ *
+ * const columns: AnalyticalTableColumnDefinition[] = [
+ * {
+ * Header: 'Input',
+ * id: 'input',
+ * Cell: (props: AnalyticalTableCellInstance) => {
+ * const callbackRef = AnalyticalTableHooks.useF2CellEdit.useCallbackRef(props);
+ * return ;
+ * },
+ * interactiveElementName: 'Input',
+ * },
+ * ];
+ *
+ * const tableHooks: AnalyticalTablePropTypes['tableHooks'] = [AnalyticalTableHooks.useF2CellEdit];
+ *
+ * function TableWithInput() {
+ * return ;
+ * }
+ * ```
+ *
+ * @param {AnalyticalTablePluginHookOptions=} [options] Optional configuration. Omit for legacy direct usage
+ * (`tableHooks={[useF2CellEdit]}`), or pass to use the factory form (`tableHooks={[useF2CellEdit({ disabled })]}`).
+ * @param {boolean=} options.disabled If `true`, the hook does nothing. To toggle at runtime, re-create the hook
+ * at the same position in the (memoized) `tableHooks` array — never add or remove array entries. Defaults to `false`.
+ *
+ * @since 2.14.0
+ */
+export const useF2CellEdit = ((hooksOrOptions?: ReactTableHooks | AnalyticalTablePluginHookOptions) => {
+ if (isHooksRegistry(hooksOrOptions)) {
+ useF2CellEditPlugin(hooksOrOptions, false);
+ return;
+ }
+ const { disabled = false } = hooksOrOptions ?? {};
+ // The returned `plugin` is pushed into `tableHooks` and invoked by react-table as a hook; threading
+ // `disabled` through this arrow is not a conditional hook call.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const plugin = (hooks: ReactTableHooks) => useF2CellEditPlugin(hooks, disabled);
+ plugin.pluginName = 'useF2CellEdit';
+ return plugin;
+}) as UseF2CellEditHook;
useF2CellEdit.pluginName = 'useF2CellEdit';
/**
@@ -304,9 +351,12 @@ function findFirstFocusableInside(element: HTMLElement) {
/**
* Init `cellContentTabIndex` if the plugin hook is used.
*/
-function useInstanceBeforeDimensions(instance: TableInstance) {
+function useInstanceBeforeDimensions(instance: TableInstance, disabled: boolean) {
const { dispatch } = instance;
useEffect(() => {
+ if (disabled) {
+ return;
+ }
dispatch({ type: 'CELL_CONTENT_TAB_INDEX', payload: -1 });
- }, [dispatch]);
+ }, [dispatch, disabled]);
}
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx b/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx
index 1e66f603d76..4d9f54b8069 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useIndeterminateRowSelection.tsx
@@ -3,7 +3,13 @@
import { useEffect, useRef } from 'react';
import { AnalyticalTableSelectionBehavior } from '../../../enums/AnalyticalTableSelectionBehavior.js';
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
-import type { AnalyticalTableState, ReactTableHooks, RowType, TableInstance } from '../types/index.js';
+import type {
+ AnalyticalTablePluginHookOptions,
+ AnalyticalTableState,
+ ReactTableHooks,
+ RowType,
+ TableInstance,
+} from '../types/index.js';
type onIndeterminateChange = (e: {
indeterminateRowsById: Record;
@@ -60,8 +66,14 @@ const getIndeterminate = (
* - This hook has to traverse the whole data tree on each selection, which can lead to performance degradation with large datasets. Please use with caution!
*
* @param {event} onIndeterminateChange Fired when the indeterminate state of rows is changed.
+ * @param {object} [options] - Additional options.
+ * @param {boolean} [options.disabled=false] - If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
*/
-export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndeterminateChange) => {
+export const useIndeterminateRowSelection = (
+ onIndeterminateChange?: onIndeterminateChange,
+ options?: AnalyticalTablePluginHookOptions,
+) => {
+ const { disabled = false } = options ?? {};
const toggleRowProps = (
rowProps: { checked?: boolean },
{ row, instance }: { row: RowType; instance: TableInstance },
@@ -138,7 +150,7 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi
const lastProcessedSelectedRowIdsRef = useRef(selectedRowIds);
useEffect(() => {
- if (lastProcessedSelectedRowIdsRef.current === selectedRowIds) {
+ if (disabled || lastProcessedSelectedRowIdsRef.current === selectedRowIds) {
return;
}
lastProcessedSelectedRowIdsRef.current = selectedRowIds;
@@ -154,19 +166,25 @@ export const useIndeterminateRowSelection = (onIndeterminateChange?: onIndetermi
} else if (typeof indeterminateRows === 'object' && Object.keys(indeterminateRows).length) {
dispatch({ type: 'INDETERMINATE_ROW_IDS', payload: 'reset' });
}
- }, [data, selectedRowIds, isTreeTable, selectionMode, selectionBehavior, dispatch]);
+ }, [data, selectedRowIds, isTreeTable, selectionMode, selectionBehavior, dispatch, disabled]);
useEffect(() => {
+ if (disabled) {
+ return;
+ }
if (typeof onIndeterminateChange === 'function' && indeterminateRows) {
onIndeterminateChange({ indeterminateRowsById: indeterminateRows, tableInstance: instance });
}
- }, [indeterminateRows]);
+ }, [indeterminateRows, disabled]);
};
const useIndeterminate = (hooks: ReactTableHooks) => {
+ hooks.useInstanceAfterData.push(useInstanceAfterData);
+ if (disabled) {
+ return;
+ }
hooks.getToggleRowSelectedProps.push(toggleRowProps);
hooks.stateReducers.push(stateReducer);
- hooks.useInstanceAfterData.push(useInstanceAfterData);
};
useIndeterminate.pluginName = 'useIndeterminate';
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts
index 6537143192d..ee03e741b1f 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useManualRowSelect.ts
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
-import type { ReactTableHooks, TableInstance } from '../types/index.js';
+import type { AnalyticalTablePluginHookOptions, ReactTableHooks, TableInstance } from '../types/index.js';
//todo: reuse `manualRowSelectedKey` react-table option (currently noop, add type again) and remove manualRowSelectedKey param here in v3.
@@ -10,22 +10,25 @@ import type { ReactTableHooks, TableInstance } from '../types/index.js';
* A plugin hook for manual row selection.
*
* @param {string} [manualRowSelectedKey='isSelected'] - If this key is found on the original data row, and it is true, this row will be manually selected.
+ * @param {object} [options] - Additional options.
+ * @param {boolean} [options.disabled=false] - If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
*
* __Note:__ Per default, this hook sets `reactTableOptions.autoResetSelectedRows = false` if not defined.
*/
-export const useManualRowSelect = (manualRowSelectedKey = 'isSelected') => {
+export const useManualRowSelect = (manualRowSelectedKey = 'isSelected', options?: AnalyticalTablePluginHookOptions) => {
+ const { disabled = false } = options ?? {};
const useInstanceAfterData = (instance: TableInstance) => {
const { flatRows, toggleRowSelected, webComponentsReactProperties } = instance;
const { selectionMode } = webComponentsReactProperties;
- if (!('autoResetSelectedRows' in instance)) {
+ if (!disabled && !('autoResetSelectedRows' in instance)) {
// `instance` is mutable
// eslint-disable-next-line react-hooks/immutability
instance.autoResetSelectedRows = false;
}
useEffect(() => {
- if (selectionMode === AnalyticalTableSelectionMode.None) {
+ if (disabled || selectionMode === AnalyticalTableSelectionMode.None) {
return;
}
@@ -37,7 +40,10 @@ export const useManualRowSelect = (manualRowSelectedKey = 'isSelected') => {
}
}
});
- }, [flatRows, toggleRowSelected, selectionMode]);
+ // `disabled` flows in via re-creating the hook at the same `tableHooks` index, so the effect must
+ // re-run when it toggles; eslint can't see that and treats it as a non-reactive outer value.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [flatRows, toggleRowSelected, selectionMode, disabled]);
};
const manualRowSelect = (hooks: ReactTableHooks) => {
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useOnColumnResize.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useOnColumnResize.ts
index 0557fe7e20d..b56903c364c 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useOnColumnResize.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useOnColumnResize.ts
@@ -4,9 +4,9 @@ import { debounce } from '@ui5/webcomponents-react-base/utils';
import type { MouseEvent, TouchEvent } from 'react';
import { useEffect, useRef } from 'react';
import { getProjectedWidth } from '../hooks/useColumnResizing.js';
-import type { ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
+import type { AnalyticalTablePluginHookOptions, ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
-interface useOnColumnResizeOptions {
+interface useOnColumnResizeOptions extends AnalyticalTablePluginHookOptions {
/**
* If `liveUpdate` is `true`, the resize function will fire while the resizer is being dragged according to the `options.wait` delay.
*/
@@ -28,8 +28,10 @@ type useOnColumnResizeFunc = (e: { columnWidth: number; header: ColumnType }) =>
* @param {Object=} options Additional options.
* @param {number=} options.wait If `liveUpdate` is `true`, the resize function will fire every time the width has changed depending on the `options.wait` delay.
* @param {boolean=} options.liveUpdate The number of milliseconds for which the calls are to be delayed. Defaults to `100`.
+ * @param {boolean=} options.disabled If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries. Defaults to `false`.
*/
export const useOnColumnResize = (callback: useOnColumnResizeFunc, options?: useOnColumnResizeOptions) => {
+ const disabled = options?.disabled ?? false;
const debouncedEvent = debounce(callback, options?.wait ?? 100);
// for liveUpdate mousemove/touchmove listeners have to be added via getResizerProps to fire the callback. Number of calls is defined by debounce value `wait`.
@@ -97,13 +99,13 @@ export const useOnColumnResize = (callback: useOnColumnResizeFunc, options?: use
const prevHeaderIsResizing = useRef(undefined);
useEffect(() => {
- if (options?.liveUpdate) {
+ if (!disabled && options?.liveUpdate) {
return () => debouncedEvent.cancel();
}
}, []);
useEffect(() => {
- if (!options?.liveUpdate) {
+ if (!disabled && !options?.liveUpdate) {
const currentHeader = columns.find((item: ColumnType) => item.id === prevHeaderIsResizing.current);
if (isResizingColumn) {
prevHeaderIsResizing.current = isResizingColumn;
@@ -115,11 +117,16 @@ export const useOnColumnResize = (callback: useOnColumnResizeFunc, options?: use
});
}
}
- }, [columnResizing, columns, isResizingColumn, columnWidths]);
+ // `disabled` changes via plugin re-registration, so it belongs in deps despite the rule's warning.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [columnResizing, columns, isResizingColumn, columnWidths, disabled]);
};
const useOnColumnResizeHooks = (hooks: ReactTableHooks) => {
hooks.useFinalInstance.push(useInstance);
+ if (disabled) {
+ return;
+ }
if (options?.liveUpdate) {
hooks.getResizerProps.push(getLiveResizerProps);
}
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useOrderedMultiSort.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useOrderedMultiSort.ts
index f07bf18bb09..ccdbf149a9a 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useOrderedMultiSort.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useOrderedMultiSort.ts
@@ -4,11 +4,17 @@
* **Note:** Column IDs that are not found in the array use the default priority, so the first sorted column has a higher priority than the next sorted column.
*
* @param {string[]} orderedIds - Array of column IDs, defining the sorting priority.
+ * @param {object} [options] - Additional options.
+ * @param {boolean} [options.disabled=false] - If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
*/
-import type { ReactTableHooks } from '../types/index.js';
+import type { AnalyticalTablePluginHookOptions, ReactTableHooks } from '../types/index.js';
-export const useOrderedMultiSort = (orderedIds: string[]) => {
+export const useOrderedMultiSort = (orderedIds: string[], options?: AnalyticalTablePluginHookOptions) => {
+ const { disabled = false } = options ?? {};
const useOrderedMultiSortPlugin = (hooks: ReactTableHooks) => {
+ if (disabled) {
+ return;
+ }
hooks.stateReducers.push((newState, action) => {
if (action.type === 'toggleSortBy') {
if (newState.sortBy.length <= 1) {
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx
index ffbfdddacde..618442b1e47 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx
@@ -3,7 +3,7 @@ import { AnalyticalTableSelectionBehavior } from '../../../enums/AnalyticalTable
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
import { CheckBox } from '../../../webComponents/CheckBox/index.js';
import { getBy } from '../react-table/index.js';
-import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js';
+import type { AnalyticalTablePluginHookOptions, ReactTableHooks, RowType, TableInstance } from '../types/index.js';
type DisableRowSelectionType = string | ((row: RowType) => boolean);
@@ -80,13 +80,20 @@ const columns = (columns: TableInstance['columns']) => {
*
* @param disableRowSelection - Can be either a `string` or a `function`. `string:` Defines the key in the dataset for disabling rows. If the value of the key is `true`, then the row will not be selectable. `function:` Programmatically disable rows for selection. The function receives the current row as parameter.
*
+ * @param {object} [options] - Additional options.
+ * @param {boolean} [options.disabled=false] - If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
+ *
* @deprecated It is not recommended to disable table rows, mainly because of the following reasons:
*
* * Users are not informed why items cannot be selected.
* * ARIA lacks built-in support for selective item selection, complicating accessibility.
* * Consistency to other applications which do not offer disabled items.
*/
-export const useRowDisableSelection = (disableRowSelection: DisableRowSelectionType) => {
+export const useRowDisableSelection = (
+ disableRowSelection: DisableRowSelectionType,
+ options?: AnalyticalTablePluginHookOptions,
+) => {
+ const { disabled = false } = options ?? {};
const disableRowAccessor =
typeof disableRowSelection === 'function'
? disableRowSelection
@@ -132,7 +139,7 @@ export const useRowDisableSelection = (disableRowSelection: DisableRowSelectionT
};
const columnDeps = (deps) => {
- return [...deps, disableRowSelection];
+ return [...deps, disableRowSelection, disabled];
};
const cellProps = (
@@ -170,10 +177,14 @@ export const useRowDisableSelection = (disableRowSelection: DisableRowSelectionT
};
const useDisableSelectionRow = (hooks: ReactTableHooks) => {
+ // Push unconditionally: feeds a `useMemo` deps array whose size must stay constant across toggles.
+ hooks.columnsDeps.push(columnDeps);
+ if (disabled) {
+ return;
+ }
hooks.getHeaderProps.push(headerProps);
hooks.getRowProps.push(getRowProps);
hooks.columns.push(columns);
- hooks.columnsDeps.push(columnDeps);
hooks.getCellProps.push(cellProps);
hooks.getToggleRowSelectedProps.push(toggleRowSelectedProps);
};
diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useStickyColumns.ts b/packages/main/src/components/AnalyticalTable/pluginHooks/useStickyColumns.ts
index 68cd7990b57..0a21f087115 100644
--- a/packages/main/src/components/AnalyticalTable/pluginHooks/useStickyColumns.ts
+++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useStickyColumns.ts
@@ -96,30 +96,35 @@ const visibleColumns = (currentVisibleColumns: ColumnType[], { instance }: { ins
return [...stickyStart, ...nonSticky];
};
-const useStickyMetadata = (instance: TableInstance, onAutoToggleSticky?: OnAutoToggleSticky) => {
+const useStickyMetadata = (instance: TableInstance, onAutoToggleSticky?: OnAutoToggleSticky, disabled = false) => {
const { visibleColumns: visCols, state, dispatch, flatHeaders } = instance;
// Native vertical-scrollbar width (0 on overlay systems); reserved in the fit check below.
const scrollbarSize = instance.webComponentsReactProperties?.scrollbarWidth ?? 0;
// Instance methods + per-column toggle (mirrors useColumnOrder / useColumnVisibility).
- // eslint-disable-next-line react-hooks/immutability
- instance.setStickyColumns = useCallback(
+ const setStickyColumns = useCallback(
(stickyColumns: string[] | ((old: string[]) => string[])) =>
dispatch({ type: actions.setStickyColumns, stickyColumns }),
[dispatch],
);
- // eslint-disable-next-line react-hooks/immutability
- instance.toggleStickyColumn = useCallback(
+ const toggleStickyColumn = useCallback(
(columnId: string, value?: boolean) => dispatch({ type: actions.toggleStickyColumn, columnId, value }),
[dispatch],
);
- flatHeaders?.forEach((column: ColumnType) => {
- if (column.disableSticky) {
- return;
- }
- column.toggleSticky = (value?: boolean) =>
- dispatch({ type: actions.toggleStickyColumn, columnId: column.id, value });
- });
+ if (!disabled) {
+ // `instance` is mutable
+ // eslint-disable-next-line react-hooks/immutability
+ instance.setStickyColumns = setStickyColumns;
+ // eslint-disable-next-line react-hooks/immutability
+ instance.toggleStickyColumn = toggleStickyColumn;
+ flatHeaders?.forEach((column: ColumnType) => {
+ if (column.disableSticky) {
+ return;
+ }
+ column.toggleSticky = (value?: boolean) =>
+ dispatch({ type: actions.toggleStickyColumn, columnId: column.id, value });
+ });
+ }
// No early returns: keep all hooks below unconditional (rules-of-hooks).
const stickySet = getStickySet(state);
@@ -131,7 +136,7 @@ const useStickyMetadata = (instance: TableInstance, onAutoToggleSticky?: OnAutoT
let totalStickyStartWidth = 0;
let autoDisabled = false;
- if (hasUserSticky) {
+ if (!disabled && hasUserSticky) {
for (let i = 0; i < visCols.length; i++) {
const col = visCols[i];
if (isStickyStart(col, stickySet)) {
@@ -165,13 +170,14 @@ const useStickyMetadata = (instance: TableInstance, onAutoToggleSticky?: OnAutoT
[stickyStartIndicesKey],
);
+ // Assign unconditionally so toggling off clears prior pins (indices are empty when disabled).
Object.assign(instance, { stickyStartIndices: stableStickyStartIndices, totalStickyStartWidth });
// Notify on width-driven enable/disable transitions (frozen-set config itself is untouched). The
// transition guard means a stable-vs-new callback identity never causes a spurious re-fire.
const prevDisabledRef = useRef(false);
useEffect(() => {
- if (!hasUserSticky || !measured) {
+ if (disabled || !hasUserSticky || !measured) {
prevDisabledRef.current = false;
return;
}
@@ -179,7 +185,7 @@ const useStickyMetadata = (instance: TableInstance, onAutoToggleSticky?: OnAutoT
prevDisabledRef.current = autoDisabled;
onAutoToggleSticky?.({ enabled: !autoDisabled, stickyColumns: state.stickyColumns ?? [] });
}
- }, [autoDisabled, hasUserSticky, measured, state.stickyColumns, onAutoToggleSticky]);
+ }, [autoDisabled, hasUserSticky, measured, state.stickyColumns, onAutoToggleSticky, disabled]);
};
const NAVIGATION_COLUMN = '__ui5wcr__internal_navigation_column';
@@ -211,6 +217,13 @@ export interface UseStickyColumnsOptions {
onStickyColumnsChange?: OnStickyColumnsChange;
/** Fired when sticky rendering auto-disables due to limited width, and again when it re-enables. */
onAutoToggleSticky?: OnAutoToggleSticky;
+ /**
+ * If `true`, the hook does nothing. To toggle at runtime, re-create the hook at the same position in
+ * the (memoized) `tableHooks` array — never add or remove array entries.
+ *
+ * @default false
+ */
+ disabled?: boolean;
}
// Contributes a Freeze/Unfreeze menu item to the column header popover (generic `columnHeaderModalItems` hook).
@@ -302,23 +315,33 @@ const setHeaderProps = (
* @param {OnAutoToggleSticky=} options.onAutoToggleSticky Fired when sticky rendering auto-disables due to
* limited width (`{ enabled: false }`) and again when it re-enables (`{ enabled: true }`). The frozen set
* itself is unchanged; use this to reflect the state change in the UI (e.g. a toast).
+ * @param {boolean=} options.disabled If `true`, the hook does nothing. To toggle at runtime, re-create the
+ * hook at the same position in the (memoized) `tableHooks` array — never add or remove array entries.
+ * Defaults to `false`.
*
* @experimental The API and behavior may change without notice.
*/
export const useStickyColumns = (options: UseStickyColumnsOptions = {}) => {
- const { onStickyColumnsChange, onAutoToggleSticky } = options;
+ const { onStickyColumnsChange, onAutoToggleSticky, disabled = false } = options;
const useStickyColumnsHooks = (hooks: ReactTableHooks) => {
- hooks.stateReducers.push(reducer);
+ // react-table calls each `useInstance` entry as a hook in stable order every render; the arrow only
+ // threads the options through, so this is not a conditional hook call. It must always be registered
+ // (stable hook count) and no-ops internally when disabled.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ hooks.useInstance.push((instance) => useStickyMetadata(instance, onAutoToggleSticky, disabled));
+ // Push unconditionally and keep `disabled` in the result: this feeds a `useMemo` deps array whose size
+ // must stay constant, and the memo must re-derive on toggle.
hooks.visibleColumnsDeps.push((deps, { instance }) => [
...deps,
instance.state.stickyColumns,
instance.state.groupBy,
+ disabled,
]);
+ if (disabled) {
+ return;
+ }
+ hooks.stateReducers.push(reducer);
hooks.visibleColumns.push(visibleColumns);
- // react-table calls each `useInstance` entry as a hook in stable order every render; the arrow only
- // threads the option through, so this is not a conditional hook call.
- // eslint-disable-next-line react-hooks/rules-of-hooks
- hooks.useInstance.push((instance) => useStickyMetadata(instance, onAutoToggleSticky));
hooks.getHeaderProps.push(setHeaderProps);
hooks.columnHeaderModalItems.push((items, meta) => getColumnHeaderModalItems(items, meta, onStickyColumnsChange));
};
diff --git a/packages/main/src/components/AnalyticalTable/test/PluginHooks.gallery.tsx b/packages/main/src/components/AnalyticalTable/test/PluginHooks.gallery.tsx
new file mode 100644
index 00000000000..8c19d3ade6e
--- /dev/null
+++ b/packages/main/src/components/AnalyticalTable/test/PluginHooks.gallery.tsx
@@ -0,0 +1,161 @@
+import { useCallback, useMemo, useState } from 'react';
+import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
+import { AnalyticalTable } from '../index.js';
+import type { AnalyticalTableColumnDefinition, AnalyticalTablePropTypes } from '../index.js';
+import * as AnalyticalTableHooks from '../pluginHooks/AnalyticalTableHooks.js';
+
+export type DisableableHook =
+ | 'useStickyColumns'
+ | 'useManualRowSelect'
+ | 'useRowDisableSelection'
+ | 'useIndeterminateRowSelection'
+ | 'useOnColumnResize'
+ | 'useAnnounceEmptyCells'
+ | 'useF2CellEdit'
+ | 'useOrderedMultiSort';
+
+interface Row {
+ name: string;
+ age: number;
+ note: string;
+ isSelected?: boolean;
+ blocked?: boolean;
+ subRows?: Row[];
+}
+
+const flatData: Row[] = [
+ { name: 'Peter', age: 40, note: '', isSelected: true, blocked: true },
+ { name: 'Kristen', age: 30, note: 'note-1', isSelected: false, blocked: false },
+ { name: 'Max', age: 20, note: '', isSelected: false, blocked: true },
+ { name: 'Anna', age: 10, note: 'note-2', isSelected: false, blocked: false },
+];
+
+const treeData: Row[] = [
+ {
+ name: 'Parent',
+ age: 1,
+ note: 'root',
+ subRows: [
+ { name: 'Child-A', age: 2, note: 'a' },
+ { name: 'Child-B', age: 3, note: 'b' },
+ ],
+ },
+];
+
+const baseColumns: AnalyticalTableColumnDefinition[] = [
+ { Header: 'Name', accessor: 'name', width: 150 },
+ { Header: 'Age', accessor: 'age', width: 150 },
+ { Header: 'Note', accessor: 'note', width: 150 },
+];
+
+const stickyColumns: AnalyticalTableColumnDefinition[] = [
+ { Header: 'Name', accessor: 'name', width: 150 },
+ { Header: 'Age', accessor: 'age', width: 150 },
+ // Non-first sticky column so the enabled reorder (hoist to start) is observable.
+ { Header: 'Note', accessor: 'note', width: 150, sticky: 'start' },
+ { Header: 'Age 2', accessor: 'age', id: 'age2', width: 150 },
+ { Header: 'Note 2', accessor: 'note', id: 'note2', width: 150 },
+];
+
+const multiSortColumns: AnalyticalTableColumnDefinition[] = [
+ { Header: 'Name', accessor: 'name', width: 150, enableMultiSort: true },
+ { Header: 'Age', accessor: 'age', width: 150, enableMultiSort: true },
+ { Header: 'Note', accessor: 'note', width: 150, enableMultiSort: true },
+];
+
+interface HookConfig {
+ columns: AnalyticalTableColumnDefinition[];
+ data: Row[];
+ containerWidth: string;
+ table: Partial;
+}
+
+function getConfig(hook: DisableableHook): HookConfig {
+ switch (hook) {
+ case 'useStickyColumns':
+ return { columns: stickyColumns, data: flatData, containerWidth: '500px', table: {} };
+ case 'useManualRowSelect':
+ return {
+ columns: baseColumns,
+ data: flatData,
+ containerWidth: '600px',
+ table: { selectionMode: AnalyticalTableSelectionMode.Multiple },
+ };
+ case 'useRowDisableSelection':
+ return {
+ columns: baseColumns,
+ data: flatData,
+ containerWidth: '600px',
+ table: { selectionMode: AnalyticalTableSelectionMode.Multiple },
+ };
+ case 'useIndeterminateRowSelection':
+ return {
+ columns: baseColumns,
+ data: treeData,
+ containerWidth: '600px',
+ table: {
+ selectionMode: AnalyticalTableSelectionMode.Multiple,
+ isTreeTable: true,
+ reactTableOptions: { selectSubRows: true },
+ },
+ };
+ case 'useOrderedMultiSort':
+ return { columns: multiSortColumns, data: flatData, containerWidth: '600px', table: { sortable: true } };
+ default:
+ return { columns: baseColumns, data: flatData, containerWidth: '600px', table: {} };
+ }
+}
+
+function useTableHooks(hook: DisableableHook, disabled: boolean, onResize: () => void) {
+ return useMemo(() => {
+ /* eslint-disable react-hooks/rules-of-hooks -- factory calls, not React hooks */
+ switch (hook) {
+ case 'useStickyColumns':
+ return [AnalyticalTableHooks.useStickyColumns({ disabled })];
+ case 'useManualRowSelect':
+ return [AnalyticalTableHooks.useManualRowSelect('isSelected', { disabled })];
+ case 'useRowDisableSelection':
+ return [AnalyticalTableHooks.useRowDisableSelection('blocked', { disabled })];
+ case 'useIndeterminateRowSelection':
+ return [AnalyticalTableHooks.useIndeterminateRowSelection(undefined, { disabled })];
+ case 'useOnColumnResize':
+ return [AnalyticalTableHooks.useOnColumnResize(onResize, { disabled })];
+ case 'useAnnounceEmptyCells':
+ return [AnalyticalTableHooks.useAnnounceEmptyCells({ disabled })];
+ case 'useF2CellEdit':
+ return [AnalyticalTableHooks.useF2CellEdit({ disabled })];
+ case 'useOrderedMultiSort':
+ return [AnalyticalTableHooks.useOrderedMultiSort(['note', 'name'], { disabled })];
+ }
+ /* eslint-enable react-hooks/rules-of-hooks */
+ }, [hook, disabled, onResize]);
+}
+
+interface DisableHooksHarnessProps {
+ hook: DisableableHook;
+ disabled?: boolean;
+}
+
+/**
+ * Mounts a single plugin hook with a `disabled` flag and a runtime toggle that re-creates the hook at the same
+ * `tableHooks` index — the supported way to flip `disabled` without violating the rules of hooks.
+ */
+export const DisableHooksHarness = ({ hook, disabled: initialDisabled = false }: DisableHooksHarnessProps) => {
+ const [disabled, setDisabled] = useState(initialDisabled);
+ const [resizeCount, setResizeCount] = useState(0);
+ const onResize = useCallback(() => setResizeCount((c) => c + 1), []);
+ const tableHooks = useTableHooks(hook, disabled, onResize);
+ const { columns, data, containerWidth, table } = getConfig(hook);
+ const style = useMemo(() => ({ width: containerWidth }), [containerWidth]);
+
+ return (
+ <>
+
+ {String(disabled)}
+ {resizeCount}
+
+ >
+ );
+};
diff --git a/packages/main/src/components/AnalyticalTable/test/PluginHooks.spec.tsx b/packages/main/src/components/AnalyticalTable/test/PluginHooks.spec.tsx
new file mode 100644
index 00000000000..5d7926292db
--- /dev/null
+++ b/packages/main/src/components/AnalyticalTable/test/PluginHooks.spec.tsx
@@ -0,0 +1,156 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '../../../../../../playwright/fixtures/gallery-fixtures.js';
+import type { DisableableHook, DisableHooksHarness } from './PluginHooks.gallery.js';
+
+const STORY = 'PluginHooks/DisableHooksHarness';
+
+const columnHeader = (page: Page, id: string) => page.locator(`[data-column-id="${id}"]`);
+const toggle = (page: Page) => page.getByTestId('toggle-disabled');
+
+const firstHeaderColumn = (page: Page) =>
+ page.locator('[data-component-name="AnalyticalTableHeaderRow"] [data-column-id]').first();
+
+async function dragFirstResizer(page: Page) {
+ const resizer = page.locator('[data-component-name="AnalyticalTableResizer"]').first();
+ await resizer.scrollIntoViewIfNeeded();
+ const box = await resizer.boundingBox();
+ if (!box) {
+ throw new Error('resizer not found');
+ }
+ const cx = box.x + box.width / 2;
+ const cy = box.y + box.height / 2;
+ await page.mouse.move(cx, cy);
+ await page.mouse.down();
+ await page.mouse.move(cx + 60, cy, { steps: 8 });
+ await page.mouse.up();
+}
+
+test.describe('AnalyticalTable plugin hooks — disabled flag', () => {
+ test('useStickyColumns: disabling unpins the seeded column; re-enabling re-pins it', async ({ mount, page }) => {
+ // Seeding (`sticky: 'start'`) happens at mount, so start enabled, then toggle off and on.
+ await mount(STORY, { hook: 'useStickyColumns' });
+ await expect(columnHeader(page, 'name')).toBeVisible();
+ await expect(page.locator('[data-sticky-start]').first()).toBeAttached();
+
+ await toggle(page).click();
+ await expect(page.locator('[data-sticky-start]')).toHaveCount(0);
+
+ await toggle(page).click();
+ await expect(page.locator('[data-sticky-start]').first()).toBeAttached();
+ });
+
+ test('useStickyColumns: disabling restores the natural order of a hoisted non-first column', async ({
+ mount,
+ page,
+ }) => {
+ await mount(STORY, { hook: 'useStickyColumns' });
+ await expect(firstHeaderColumn(page)).toHaveAttribute('data-column-id', 'note');
+
+ await toggle(page).click();
+ await expect(firstHeaderColumn(page)).toHaveAttribute('data-column-id', 'name');
+
+ await toggle(page).click();
+ await expect(firstHeaderColumn(page)).toHaveAttribute('data-column-id', 'note');
+ });
+
+ test('useManualRowSelect: disabled selects no row; enabling applies the data selection', async ({ mount, page }) => {
+ // Selected cells carry `aria-selected`; the first data row (`isSelected: true`) has selected gridcells.
+ // Disabling only stops the hook from *applying* the data selection — it does not clear an existing one.
+ const selectedCells = page.locator('[role="gridcell"][aria-selected="true"]');
+ await mount(STORY, { hook: 'useManualRowSelect', disabled: true });
+ await expect(page.getByText('Peter')).toBeVisible();
+ await expect(selectedCells).toHaveCount(0);
+
+ await toggle(page).click();
+ await expect(selectedCells.first()).toBeVisible();
+ });
+
+ test('useRowDisableSelection: disabled leaves all rows selectable; toggling disables blocked rows', async ({
+ mount,
+ page,
+ }) => {
+ // Blocked rows get their selection cell marked `aria-disabled`.
+ const disabledSelectionCells = page.locator('[role="gridcell"][aria-disabled="true"]');
+ await mount(STORY, { hook: 'useRowDisableSelection', disabled: true });
+ await expect(page.getByText('Peter')).toBeVisible();
+ await expect(disabledSelectionCells).toHaveCount(0);
+
+ await toggle(page).click();
+ // two rows carry `blocked: true` → two disabled selection cells
+ await expect(disabledSelectionCells).toHaveCount(2);
+
+ await toggle(page).click();
+ await expect(disabledSelectionCells).toHaveCount(0);
+ });
+
+ test('useOnColumnResize: disabled suppresses the callback; toggling re-enables it', async ({ mount, page }) => {
+ await mount(STORY, { hook: 'useOnColumnResize', disabled: true });
+ await expect(columnHeader(page, 'name')).toBeVisible();
+
+ await dragFirstResizer(page);
+ await expect(page.getByTestId('resize-count')).toHaveText('0');
+
+ await toggle(page).click();
+ await dragFirstResizer(page);
+ await expect(page.getByTestId('resize-count')).toHaveText('1');
+ });
+
+ test('useAnnounceEmptyCells: disabled omits the empty-cell description; toggling adds it', async ({
+ mount,
+ page,
+ }) => {
+ await mount(STORY, { hook: 'useAnnounceEmptyCells', disabled: true });
+ const emptyCell = page.locator('[data-column-id-cell="note"]').first();
+ await expect(emptyCell).toBeVisible();
+ const disabledLabelledBy = (await emptyCell.getAttribute('aria-labelledby')) ?? '';
+
+ await toggle(page).click();
+ await expect
+ .poll(async () => ((await emptyCell.getAttribute('aria-labelledby')) ?? '').split(/\s+/).filter(Boolean).length)
+ .toBe(disabledLabelledBy.split(/\s+/).filter(Boolean).length + 1);
+ });
+
+ test('useF2CellEdit: disabled omits the F2 table description; toggling adds it', async ({ mount, page }) => {
+ await mount(STORY, { hook: 'useF2CellEdit', disabled: true });
+ const grid = page.getByRole('grid');
+ await expect(grid).toBeVisible();
+ const disabledDescription = await grid.getAttribute('aria-description');
+
+ await toggle(page).click();
+ await expect.poll(async () => grid.getAttribute('aria-description')).not.toBe(disabledDescription);
+ expect(await grid.getAttribute('aria-description')).toBeTruthy();
+ });
+
+ // The core rules-of-hooks guarantee: flipping `disabled` on a mounted table (re-creating the hook at the same
+ // `tableHooks` index) must not change the hook count/order, so React never throws "rendered more/fewer hooks".
+ const allHooks: DisableableHook[] = [
+ 'useStickyColumns',
+ 'useManualRowSelect',
+ 'useRowDisableSelection',
+ 'useIndeterminateRowSelection',
+ 'useOnColumnResize',
+ 'useAnnounceEmptyCells',
+ 'useF2CellEdit',
+ 'useOrderedMultiSort',
+ ];
+
+ for (const hook of allHooks) {
+ test(`${hook}: toggling disabled at runtime does not crash the table`, async ({ mount, page }) => {
+ const errors: string[] = [];
+ page.on('pageerror', (err) => errors.push(err.message));
+
+ await mount(STORY, { hook });
+ await expect(columnHeader(page, 'name')).toBeVisible();
+
+ await toggle(page).click();
+ await expect(page.getByTestId('disabled-state')).toHaveText('true');
+ await expect(columnHeader(page, 'name')).toBeVisible();
+
+ await toggle(page).click();
+ await expect(page.getByTestId('disabled-state')).toHaveText('false');
+ await expect(columnHeader(page, 'name')).toBeVisible();
+
+ expect(errors).toEqual([]);
+ });
+ }
+});
diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts
index a7be2f947e1..b34537fa2c5 100644
--- a/packages/main/src/components/AnalyticalTable/types/index.ts
+++ b/packages/main/src/components/AnalyticalTable/types/index.ts
@@ -1339,6 +1339,23 @@ export type PluginHook = {
pluginName: string;
};
+/**
+ * Options common to all AnalyticalTable plugin hooks.
+ */
+export interface AnalyticalTablePluginHookOptions {
+ /**
+ * If `true`, the plugin hook does nothing.
+ *
+ * Since plugin hooks cannot be added to or removed from the `tableHooks` array at runtime (doing so would
+ * violate the rules of hooks), use this flag to disable a hook while keeping it in the array. To toggle at
+ * runtime, re-create the hook at the **same position** in the (memoized) `tableHooks` array with the new
+ * `disabled` value — never add or remove array entries, and keep their order stable.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+}
+
export interface FilterFn {
(rows: RowType[], columnIds: string[], filterValue: any): RowType[];
autoRemove?: (filterValue: any, column?: any) => boolean;