From 6034d14c9049e45a9444011bbf9c4a040e679743 Mon Sep 17 00:00:00 2001 From: Ivan Runets Date: Mon, 23 Feb 2026 11:03:11 +0300 Subject: [PATCH 01/22] fix: DataTable accessibility issues --- .../tables/filteredTable/FilteredTable.tsx | 3 +- .../MasterDetailedTable.tsx | 1 + changelog.md | 11 + public/docs/docsGenOutput/docsGenOutput.d.ts | 1 + public/docs/docsGenOutput/docsGenOutput.json | 239 +++++++++++++----- uui/components/overlays/DropdownMenu.tsx | 17 +- .../ColumnHeaderDropdown/SortingPanel.tsx | 9 +- uui/components/tables/DataTable.tsx | 6 +- .../ColumnHeaderDropdown.test.tsx.snap | 6 +- .../ColumnsConfigurationModal.tsx | 2 +- 10 files changed, 227 insertions(+), 68 deletions(-) diff --git a/app/src/demo/tables/filteredTable/FilteredTable.tsx b/app/src/demo/tables/filteredTable/FilteredTable.tsx index 51024fa452..de4e8f1cb2 100644 --- a/app/src/demo/tables/filteredTable/FilteredTable.tsx +++ b/app/src/demo/tables/filteredTable/FilteredTable.tsx @@ -79,7 +79,7 @@ export function FilteredTable() { return (
- + Users Dashboard @@ -101,6 +101,7 @@ export function FilteredTable() { showColumnsConfig={ true } allowColumnsResizing={ true } allowColumnsReordering={ true } + rawProps={ { 'aria-labelledby': 'presets-title' } } { ...listProps } /> diff --git a/app/src/demo/tables/masterDetailedTable/MasterDetailedTable.tsx b/app/src/demo/tables/masterDetailedTable/MasterDetailedTable.tsx index ba06184ef1..bae6646ec1 100644 --- a/app/src/demo/tables/masterDetailedTable/MasterDetailedTable.tsx +++ b/app/src/demo/tables/masterDetailedTable/MasterDetailedTable.tsx @@ -117,6 +117,7 @@ export function MasterDetailedTable() { showColumnsConfig={ true } allowColumnsResizing allowColumnsReordering + rawProps={ { 'aria-label': 'Users Dashboard' } } { ...view.getListProps() } />
diff --git a/changelog.md b/changelog.md index a4cfcd91ec..0757e306a6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,14 @@ +# 6.4.x - xx.xx.2026 + +**What's New** +* [DropdownMenu]: + * `IDropdownMenuItemProps` now extends `IHasRawProps`; use `rawProps` to pass any HTML attributes to the menu item element. + * Menu arrow-key navigation now includes all roles starting with `menuitem` (e.g. `menuitem`, `menuitemradio`, `menuitemcheckbox`). + +**What's Fixed** +* [SortingPanel]: Sort options in the column header dropdown are exposed as `role="menuitemradio"` with `aria-checked`, so screen readers announce the selected sort direction ([#2992](https://github.com/epam/UUI/issues/2992) Case 2). +* [DataTable]: Table accepts `rawProps` for additional ARIA/HTML attributes (e.g. `aria-label` for table name); `role`, `aria-colcount`, and `aria-rowcount` remain controlled by the component. ([#2992](https://github.com/epam/UUI/issues/2992) Case 3) + # 6.4.3 - 04.02.2026 **What's New** diff --git a/public/docs/docsGenOutput/docsGenOutput.d.ts b/public/docs/docsGenOutput/docsGenOutput.d.ts index b40bfe0705..adf28f37a4 100644 --- a/public/docs/docsGenOutput/docsGenOutput.d.ts +++ b/public/docs/docsGenOutput/docsGenOutput.d.ts @@ -63,6 +63,7 @@ type Autogenerated_TDocsGenExportedTypeRef = '@epam/uui-core:AcceptDropParams' | '@epam/uui-core:DropPosition' | '@epam/uui-core:DropPositionOptions' | '@epam/uui-core:ErrorPageInfo' | +'@epam/uui-core:FetchingOptions' | '@epam/uui-core:FileUploadOptions' | '@epam/uui-core:FileUploadResponse' | '@epam/uui-core:FilterConfig' | diff --git a/public/docs/docsGenOutput/docsGenOutput.json b/public/docs/docsGenOutput/docsGenOutput.json index a87a1d9377..a04ecc942b 100644 --- a/public/docs/docsGenOutput/docsGenOutput.json +++ b/public/docs/docsGenOutput/docsGenOutput.json @@ -1,5 +1,5 @@ { - "version": "6.3.2-alpha.0", + "version": "6.4.3", "docsGenTypes": { "@epam/uui-core:AcceptDropParams": { "summary": { @@ -189,6 +189,8 @@ " /** Request error message */", " errorMessage?: string;", " };", + " /** Request error */", + " error?: Error;", " /** Request error status */", " errorStatus?: number;", " /** Timestamp of request start */", @@ -336,6 +338,20 @@ }, "required": false }, + { + "uid": "error", + "name": "error", + "comment": { + "raw": [ + "Request error" + ] + }, + "typeValue": { + "raw": "Error", + "html": "Error" + }, + "required": false + }, { "uid": "errorStatus", "name": "errorStatus", @@ -703,9 +719,9 @@ "details": { "kind": 265, "typeValue": { - "raw": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance'", + "raw": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance' | 'abort-signal'", "print": [ - "type ApiRecoveryReason = 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance' | null;" + "type ApiRecoveryReason = 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance' | 'abort-signal' | null;" ] } } @@ -2293,8 +2309,8 @@ ] }, "typeValue": { - "raw": "() => Promise", - "html": "() => Promise<TItem[]>" + "raw": "(options: FetchingOptions) => Promise", + "html": "(options: FetchingOptions) => Promise<TItem[]>" }, "editor": { "type": "func" @@ -2732,8 +2748,8 @@ ] }, "typeValue": { - "raw": "() => Promise", - "html": "() => Promise<TItem[]>" + "raw": "(options: FetchingOptions) => Promise", + "html": "(options: FetchingOptions) => Promise<TItem[]>" }, "editor": { "type": "func" @@ -13395,6 +13411,56 @@ "propsFromUnion": false } }, + "@epam/uui-core:FetchingOptions": { + "summary": { + "module": "@epam/uui-core", + "typeName": { + "name": "FetchingOptions", + "nameFull": "FetchingOptions" + }, + "src": "uui-core/src/services/ApiContext.ts", + "comment": { + "raw": [ + "Options which are passed to the HTTP request." + ] + }, + "exported": true + }, + "details": { + "kind": 264, + "typeValue": { + "raw": "FetchingOptions", + "print": [ + "/**", + " * Options which are passed to the HTTP request.", + " */", + "interface FetchingOptions {", + " /**", + " * Signal of request aborting.", + " */", + " signal: AbortSignal;", + "}" + ] + }, + "props": [ + { + "uid": "signal", + "name": "signal", + "comment": { + "raw": [ + "Signal of request aborting." + ] + }, + "typeValue": { + "raw": "AbortSignal", + "html": "AbortSignal" + }, + "required": true + } + ], + "propsFromUnion": false + } + }, "@epam/uui-core:FileUploadOptions": { "summary": { "module": "@epam/uui-core", @@ -14646,7 +14712,9 @@ "raw": "FormProps", "print": [ "interface FormProps {", - " /** Current value of the form state */", + " /** Initial Form value", + " * If changed after initialization, form will be reverted to the new value", + " * */", " value: T;", " /**", " * Render the form body, provided by form state", @@ -14708,7 +14776,8 @@ "name": "value", "comment": { "raw": [ - "Current value of the form state" + "Initial Form value", + " If changed after initialization, form will be reverted to the new value" ] }, "typeValue": { @@ -15336,8 +15405,8 @@ ] }, "typeValue": { - "raw": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance'", - "html": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance'" + "raw": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance' | 'abort-signal'", + "html": "null | 'auth-lost' | 'connection-lost' | 'server-overload' | 'maintenance' | 'abort-signal'" }, "typeValueRef": "@epam/uui-core:ApiRecoveryReason", "editor": { @@ -15347,6 +15416,7 @@ "connection-lost", "server-overload", "maintenance", + "abort-signal", null ] }, @@ -24557,7 +24627,7 @@ "/** Defines input arguments for Lazy Data Source APIs */", "request: LazyDataSourceApiRequest, ", "/** Defines the context of API request. */", - "context?: LazyDataSourceApiRequestContext) => Promise>;" + "context: LazyDataSourceApiRequestContext) => Promise>;" ] } } @@ -24788,7 +24858,7 @@ "raw": "LazyDataSourceApiRequestContext", "print": [ "/** Defines the context of API request. E.g. parent if we require to retrieve sub-list of the tree */", - "interface LazyDataSourceApiRequestContext {", + "interface LazyDataSourceApiRequestContext extends FetchingOptions {", " /**", " * The ID of the parent item whose children are being requested.", " * Used for lazy-loading data in tree lists.", @@ -24828,6 +24898,21 @@ "html": "null | TItem" }, "required": false + }, + { + "uid": "signal", + "name": "signal", + "comment": { + "raw": [ + "Signal of request aborting." + ] + }, + "typeValue": { + "raw": "AbortSignal", + "html": "AbortSignal" + }, + "from": "@epam/uui-core:FetchingOptions", + "required": true } ], "propsFromUnion": false @@ -41879,7 +41964,8 @@ "name": "value", "comment": { "raw": [ - "Current value of the form state" + "Initial Form value", + " If changed after initialization, form will be reverted to the new value" ] }, "typeValue": { @@ -43549,8 +43635,8 @@ ] }, "typeValue": { - "raw": "() => Promise", - "html": "() => Promise<TItem[]>" + "raw": "(options: FetchingOptions) => Promise", + "html": "(options: FetchingOptions) => Promise<TItem[]>" }, "editor": { "type": "func" @@ -50842,7 +50928,7 @@ "editor": { "type": "bool" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -50857,7 +50943,7 @@ "raw": "string | React.ComponentClass & { children: React.ReactNode; }, any> | React.FunctionComponent & { children: React.ReactNode; }>", "html": "string | React.ComponentClass<Record<string, any> & { children: React.ReactNode; }, any> | React.FunctionComponent<Record<string, any> & { children: React.ReactNode; }>" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -50872,7 +50958,7 @@ "raw": "(HTMLElement | React.RefObject)[]", "html": "(HTMLElement | React.RefObject<any>)[]" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -50892,7 +50978,7 @@ "raw": "boolean | FocusOptions | (returnTo: Element) => boolean | FocusOptions", "html": "boolean | FocusOptions | (returnTo: Element) => boolean | FocusOptions" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false } ], @@ -66460,6 +66546,17 @@ "exported": false } }, + "@epam/uui-components:ReactFocusLockProps": { + "summary": { + "module": "@epam/uui-components", + "typeName": { + "name": "ReactFocusLockProps", + "nameFull": "ReactFocusLockProps" + }, + "src": "uui-components/node_modules/react-focus-lock/dist/cjs/interfaces.d.ts", + "exported": false + } + }, "@epam/uui-components:SelectionManager": { "summary": { "module": "@epam/uui-components", @@ -72620,24 +72717,6 @@ "from": "@epam/uui:DataRowAddonsCoreProps", "required": false }, - { - "uid": "isFoldingFocusable", - "name": "isFoldingFocusable", - "comment": { - "raw": [ - "If true, folding arrow is focusable" - ] - }, - "typeValue": { - "raw": "boolean", - "html": "boolean" - }, - "editor": { - "type": "bool" - }, - "from": "@epam/uui:DataRowAddonsCoreProps", - "required": false - }, { "uid": "size", "name": "size", @@ -75207,6 +75286,21 @@ "from": "@epam/uui-core:UseVirtualListProps", "required": false }, + { + "uid": "rawProps", + "name": "rawProps", + "comment": { + "raw": [ + "Any HTML attributes (native or 'data-') to put on the underlying component" + ] + }, + "typeValue": { + "raw": "React.HTMLAttributes & Record<`data-${string}`, string>", + "html": "React.HTMLAttributes<HTMLDivElement> & Record<`data-${string}`, string>" + }, + "from": "@epam/uui-core:IHasRawProps", + "required": false + }, { "uid": "size", "name": "size", @@ -77587,7 +77681,7 @@ "editor": { "type": "bool" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -77602,7 +77696,7 @@ "raw": "string | React.ComponentClass & { children: React.ReactNode; }, any> | React.FunctionComponent & { children: React.ReactNode; }>", "html": "string | React.ComponentClass<Record<string, any> & { children: React.ReactNode; }, any> | React.FunctionComponent<Record<string, any> & { children: React.ReactNode; }>" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -77617,7 +77711,7 @@ "raw": "(HTMLElement | React.RefObject)[]", "html": "(HTMLElement | React.RefObject<any>)[]" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -77637,7 +77731,7 @@ "raw": "boolean | FocusOptions | (returnTo: Element) => boolean | FocusOptions", "html": "boolean | FocusOptions | (returnTo: Element) => boolean | FocusOptions" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false }, { @@ -80560,7 +80654,7 @@ "typeValue": { "raw": "IDropdownMenuItemProps", "print": [ - "interface IDropdownMenuItemProps extends IDropdownTogglerProps, IHasCaption, IHasIcon, ICanRedirect, IHasCX, IDisableable, IAnalyticableClick {", + "interface IDropdownMenuItemProps extends IDropdownTogglerProps, IHasCaption, IHasIcon, ICanRedirect, IHasCX, IDisableable, IAnalyticableClick, IHasRawProps> {", " isSelected?: boolean;", " isActive?: boolean;", " indent?: boolean;", @@ -80940,6 +81034,21 @@ }, "from": "@epam/uui-core:IAnalyticableClick", "required": false + }, + { + "uid": "rawProps", + "name": "rawProps", + "comment": { + "raw": [ + "Any HTML attributes (native or 'data-') to put on the underlying component" + ] + }, + "typeValue": { + "raw": "React.HTMLAttributes & Record<`data-${string}`, string>", + "html": "React.HTMLAttributes<HTMLDivElement> & Record<`data-${string}`, string>" + }, + "from": "@epam/uui-core:IHasRawProps", + "required": false } ], "propsFromUnion": false @@ -81308,7 +81417,7 @@ "raw": "(HTMLElement | React.RefObject)[]", "html": "(HTMLElement | React.RefObject<any>)[]" }, - "from": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps", + "from": "@epam/uui-components:ReactFocusLockProps", "required": false } ], @@ -96680,6 +96789,11 @@ " * @default 'none'", " */", " overflowBottomEffect?: 'line' | 'shadow' | 'none';", + " /**", + " * The given Event(s) from the elements with the given selector(s) will trigger an update.", + " * Useful for cases where OverlayScrollbars' default logic does not detect changes, such as shadow DOM size changes.", + " */", + " elementEvents?: Options['update']['elementEvents'];", " children?: ReactNode | undefined;", "};" ] @@ -96843,6 +96957,21 @@ }, "required": false }, + { + "uid": "elementEvents", + "name": "elementEvents", + "comment": { + "raw": [ + "The given Event(s) from the elements with the given selector(s) will trigger an update.", + " Useful for cases where OverlayScrollbars' default logic does not detect changes, such as shadow DOM size changes." + ] + }, + "typeValue": { + "raw": "null | [elementSelector: string, eventNames: string][]", + "html": "null | [elementSelector: string, eventNames: string][]" + }, + "required": false + }, { "uid": "children", "name": "children", @@ -117758,9 +117887,9 @@ " * Icon click handler.", " */", " getHandlerIcon?: (value: number) => Icon;", - " /*", - " * Defines Tooltip color.", - " */", + " /**", + " * Defines Tooltip color.", + " */", " tooltipColor?: 'white' | 'fire' | 'gray';", "}" ] @@ -117891,6 +118020,11 @@ { "uid": "tooltipColor", "name": "tooltipColor", + "comment": { + "raw": [ + "Defines Tooltip color." + ] + }, "typeValue": { "raw": "'fire' | 'white' | 'gray'", "html": "'fire' | 'white' | 'gray'" @@ -120558,17 +120692,6 @@ "exported": false } }, - "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts:ReactFocusLockProps": { - "summary": { - "module": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts", - "typeName": { - "name": "ReactFocusLockProps", - "nameFull": "ReactFocusLockProps" - }, - "src": "node_modules/react-focus-lock/dist/cjs/interfaces.d.ts", - "exported": false - } - }, "node_modules/typescript/lib/lib.dom.d.ts:Animatable": { "summary": { "module": "node_modules/typescript/lib/lib.dom.d.ts", diff --git a/uui/components/overlays/DropdownMenu.tsx b/uui/components/overlays/DropdownMenu.tsx index f304b7e537..456a57b2bb 100644 --- a/uui/components/overlays/DropdownMenu.tsx +++ b/uui/components/overlays/DropdownMenu.tsx @@ -20,6 +20,7 @@ import { getDir, uuiMarkers, isEventTargetInsideClickable, + IHasRawProps, } from '@epam/uui-core'; import { Text, Anchor, IconContainer, Dropdown, FlexSpacer } from '@epam/uui-components'; import type { DropdownContainerProps } from '@epam/uui-components'; @@ -30,7 +31,16 @@ import { settings } from '../../settings'; import css from './DropdownMenu.module.scss'; -export interface IDropdownMenuItemProps extends IDropdownTogglerProps, IHasCaption, IHasIcon, ICanRedirect, IHasCX, IDisableable, IAnalyticableClick { +export interface IDropdownMenuItemProps + extends + IDropdownTogglerProps, + IHasCaption, + IHasIcon, + ICanRedirect, + IHasCX, + IDisableable, + IAnalyticableClick, + IHasRawProps> { isSelected?: boolean; isActive?: boolean; indent?: boolean; @@ -56,7 +66,7 @@ function DropdownMenuContainer(props: DropdownMenuContainerProps) { const getMenuItems = (): HTMLElement[] => { if (!menuRef.current) return []; - return Array.from(menuRef.current.querySelectorAll(`[role="menuitem"]:not(.${uuiMod.disabled})`)); + return Array.from(menuRef.current.querySelectorAll(`[role^="menuitem"]:not(.${uuiMod.disabled})`)); }; const changeFocus = (nextFocusedIndex: number) => { @@ -174,7 +184,7 @@ export const DropdownMenuButton = React.forwardRef( cx={ cx(css.link, itemClassNames) } link={ link } href={ href } - rawProps={ { role: 'menuitem', tabIndex: isDisabled ? -1 : 0 } } + rawProps={ { role: 'menuitem', tabIndex: isDisabled ? -1 : 0, ...props.rawProps } } onClick={ handleClick } isDisabled={ isDisabled } target={ target } @@ -189,6 +199,7 @@ export const DropdownMenuButton = React.forwardRef( className={ itemClassNames } onClick={ handleClick } // update flex row data ref={ ref } + { ...props.rawProps } > { getMenuButtonContent() } { isSelected && ( diff --git a/uui/components/tables/ColumnHeaderDropdown/SortingPanel.tsx b/uui/components/tables/ColumnHeaderDropdown/SortingPanel.tsx index 91de542ca3..5f19c7be55 100644 --- a/uui/components/tables/ColumnHeaderDropdown/SortingPanel.tsx +++ b/uui/components/tables/ColumnHeaderDropdown/SortingPanel.tsx @@ -16,19 +16,24 @@ const SortingPanelImpl: React.FC = ({ sortDirection, onSort } const sortAsc = useCallback(() => onSort(sortDirection === 'asc' ? undefined : 'asc'), [onSort]); const sortDesc = useCallback(() => onSort(sortDirection === 'desc' ? undefined : 'desc'), [onSort]); + const isAscSelected = sortDirection === 'asc'; + const isDescSelected = sortDirection === 'desc'; + return ( ); diff --git a/uui/components/tables/DataTable.tsx b/uui/components/tables/DataTable.tsx index d018997c68..4534fb52f6 100644 --- a/uui/components/tables/DataTable.tsx +++ b/uui/components/tables/DataTable.tsx @@ -3,6 +3,7 @@ import { ColumnsConfig, DataRowProps, useUuiContext, uuiScrollShadows, useColumnsConfig, IEditable, DataTableState, DataTableColumnsConfigOptions, DataSourceListProps, DataColumnProps, cx, TableFiltersConfig, DataTableRowProps, DataTableSelectedCellData, Overwrite, DataColumnGroupProps, IHasCX, + IHasRawProps, } from '@epam/uui-core'; import { IconContainer, DataTableSelectionProvider, DataTableFocusManager, DataTableFocusProvider } from '@epam/uui-components'; import { useColumnsWithFilters } from '../../helpers'; @@ -20,7 +21,9 @@ import { settings } from '../../settings'; import './variables.scss'; import css from './DataTable.module.scss'; -interface DataTableCoreProps extends IEditable, IHasCX, DataSourceListProps, DataTableColumnsConfigOptions, Pick { +type DataTableRawProps = IHasRawProps, 'role' | 'aria-colcount' | 'aria-rowcount'>>; + +interface DataTableCoreProps extends IEditable, IHasCX, DataSourceListProps, DataTableColumnsConfigOptions, Pick, DataTableRawProps { /** Callback to get rows that will be rendered in table */ getRows?(): DataRowProps[]; @@ -199,6 +202,7 @@ export function DataTable(props: DataTableProps) { role: 'table', 'aria-colcount': columns.length, 'aria-rowcount': props.rowsCount, + ...props.rawProps, }; return ( diff --git a/uui/components/tables/__tests__/__snapshots__/ColumnHeaderDropdown.test.tsx.snap b/uui/components/tables/__tests__/__snapshots__/ColumnHeaderDropdown.test.tsx.snap index 6baa23e1b9..1df53cbe4f 100644 --- a/uui/components/tables/__tests__/__snapshots__/ColumnHeaderDropdown.test.tsx.snap +++ b/uui/components/tables/__tests__/__snapshots__/ColumnHeaderDropdown.test.tsx.snap @@ -40,8 +40,9 @@ exports[`ColumnHeaderDropdown should be rendered correctly 1`] = ` style="min-width: 0; flex-basis: 0px;" >