feat(components): [table-v2] accessibility enhancement (#22983)

* feat(components): [table-v2] accessibility enhancement

* chore: add test

* chore: formatted
This commit is contained in:
qiang
2025-12-13 20:27:41 +08:00
committed by GitHub
parent bec944e9d8
commit 974ba4d3bf
11 changed files with 153 additions and 22 deletions

View File

@@ -9,13 +9,14 @@
</template>
<script lang="tsx" setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import {
ElButton,
ElCheckbox,
ElIcon,
ElPopover,
TableV2FixedDir,
useLocale,
} from 'element-plus'
import { Filter } from '@element-plus/icons-vue'
@@ -50,9 +51,14 @@ const generateData = (
const columns = generateColumns(10)
const data = ref(generateData(columns, 200))
const { t } = useLocale()
const shouldFilter = ref(false)
const popoverRef = ref()
const ariaLabel = computed(() => {
return t('el.table.filterLabel', { column: columns[0].title })
})
const onFilter = () => {
popoverRef.value.hide()
if (shouldFilter.value) {
@@ -67,11 +73,21 @@ const onReset = () => {
onFilter()
}
const handleShowPopover = () => {
const button = document.querySelector('.el-table-v2__demo-filter button')
;(button as HTMLElement)?.focus()
}
columns[0].headerCellRenderer = (props: HeaderCellSlotProps) => {
return (
<div class="flex items-center justify-center">
<span class="mr-2 text-xs">{props.column.title}</span>
<ElPopover ref={popoverRef} trigger="click" {...{ width: 200 }}>
<ElPopover
ref={popoverRef}
trigger="click"
width={200}
onAfter-enter={handleShowPopover}
>
{{
default: () => (
<div class="filter-wrapper">
@@ -91,9 +107,15 @@ columns[0].headerCellRenderer = (props: HeaderCellSlotProps) => {
</div>
),
reference: () => (
<ElIcon class="cursor-pointer">
<Filter />
</ElIcon>
<button
type="button"
aria-label={ariaLabel.value}
class="el-table-v2__demo-filter-btn"
>
<ElIcon size={14}>
<Filter />
</ElIcon>
</button>
),
}}
</ElPopover>
@@ -117,4 +139,13 @@ const fixedColumns = columns.map((column, columnIndex) => {
display: flex;
justify-content: space-between;
}
.el-table-v2__demo-filter-btn {
display: flex;
cursor: pointer;
padding: 0;
margin: 0;
background-color: transparent;
appearance: none;
border: none;
}
</style>

View File

@@ -16,7 +16,7 @@
<script lang="tsx" setup>
import { ref, unref } from 'vue'
import { ElCheckbox } from 'element-plus'
import { ElCheckbox, useLocale } from 'element-plus'
import type { FunctionalComponent } from 'vue'
import type { CheckboxValueType, Column } from 'element-plus'
@@ -24,18 +24,23 @@ import type { CheckboxValueType, Column } from 'element-plus'
type SelectionCellProps = {
value: boolean
intermediate?: boolean
ariaLabel?: string
onChange: (value: CheckboxValueType) => void
}
const { t } = useLocale()
const SelectionCell: FunctionalComponent<SelectionCellProps> = ({
value,
intermediate = false,
ariaLabel,
onChange,
}) => {
return (
<ElCheckbox
onChange={onChange}
modelValue={value}
ariaLabel={ariaLabel}
indeterminate={intermediate}
/>
)
@@ -75,7 +80,13 @@ columns.unshift({
width: 50,
cellRenderer: ({ rowData }) => {
const onChange = (value: CheckboxValueType) => (rowData.checked = value)
return <SelectionCell value={rowData.checked} onChange={onChange} />
return (
<SelectionCell
value={rowData.checked}
ariaLabel={t('el.table.selectRowLabel')}
onChange={onChange}
/>
)
},
headerCellRenderer: () => {
@@ -92,6 +103,7 @@ columns.unshift({
<SelectionCell
value={allSelected}
intermediate={containsChecked && !allSelected}
ariaLabel={t('el.table.selectAllLabel')}
onChange={onChange}
/>
)

View File

@@ -116,7 +116,7 @@ const treeData = computed(() => unflatten(data))
const expandedRowKeys = ref<string[]>([])
const onRowExpanded = ({ expanded }: Parameters<RowExpandHandler<any>>[0]) => {
const onRowExpanded = ({ expanded }: Parameters<RowExpandHandler>[0]) => {
console.log('Expanded:', expanded)
}

View File

@@ -2,11 +2,13 @@ import { h, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'
import TableV2 from '../src/table-v2'
import { TableV2SortOrder } from '../index'
import type {
TableV2HeaderRowCellRendererParams,
TableV2RowCellRenderParam,
} from '../src/components'
import type { SortBy } from '../src/types'
const generateColumns = (length = 10, prefix = 'column-', props?: any) =>
Array.from({ length }).map((_, columnIndex) => ({
@@ -218,4 +220,62 @@ describe('TableV2.vue', () => {
expect(cell.exists()).toBe(true)
expect(cell.find('div [style^=margin-inline-star]').exists()).toBe(false)
})
describe('a11y', () => {
test('expand button', async () => {
const columns = generateColumns(10)
const data = [
{
id: 0,
[columns[0].dataKey]: 'Row 0 - Col 0',
children: generateData(columns, 20),
},
]
const wrapper = mount(() => (
<TableV2
columns={columns}
data={data}
width={700}
height={400}
expand-column-key="column-0"
/>
))
const expandButton = wrapper.find('.el-table-v2__expand-icon')
expect(expandButton.attributes('arialabel')).toBe('Expand this row')
expect(expandButton.attributes('ariaexpanded')).toBe('false')
await expandButton.trigger('click')
await nextTick()
expect(expandButton.attributes('ariaexpanded')).toBe('true')
})
test('sort button', async () => {
const sortState = ref<SortBy>({
key: 'column-0',
order: TableV2SortOrder.ASC,
})
const columns = generateColumns(10)
const data = generateData(columns, 20)
columns[0].sortable = true
const wrapper = mount(() => (
<TableV2
columns={columns}
data={data}
width={700}
height={400}
sortBy={sortState.value}
/>
))
const sortButton = wrapper.find('.el-table-v2__sort-icon')
const header = wrapper.find('.el-table-v2__header-cell.is-sortable')
console.log(header.attributes())
expect(sortButton.attributes('aria-label')).toBe('Sort by Column 0')
expect(header.attributes('ariasort')).toBe('ascending')
expect(header.attributes('role')).toBe('columnheader')
})
})
})

View File

@@ -8,22 +8,27 @@ const ExpandIcon = (
props: TableV2RowCellRenderParam['expandIconProps'] & {
class?: string | string[]
style: StyleValue
ariaLabel?: string
size: number
expanded: boolean
expandable: boolean
}
) => {
const { expanded, expandable, onExpand, style, size } = props
const { expanded, expandable, onExpand, style, size, ariaLabel } = props
const expandIconProps = {
onClick: expandable ? () => onExpand(!expanded) : undefined,
ariaLabel,
ariaExpanded: expanded,
class: props.class,
} as any
return (
<ElIcon {...expandIconProps} size={size} style={style}>
<ArrowRight />
</ElIcon>
<button {...expandIconProps} type="button">
<ElIcon size={size} style={style}>
<ArrowRight />
</ElIcon>
</button>
)
}

View File

@@ -6,6 +6,7 @@ import type { FunctionalComponent } from 'vue'
export type SortIconProps = {
sortOrder: SortOrder
ariaLabel?: string
class?: JSX.IntrinsicAttributes['class']
}
@@ -13,9 +14,11 @@ const SortIcon: FunctionalComponent<SortIconProps> = (props) => {
const { sortOrder } = props
return (
<ElIcon size={14} class={props.class}>
{sortOrder === SortOrder.ASC ? <SortUp /> : <SortDown />}
</ElIcon>
<button type="button" aria-label={props.ariaLabel} class={props.class}>
<ElIcon size={14}>
{sortOrder === SortOrder.ASC ? <SortUp /> : <SortDown />}
</ElIcon>
</button>
)
}

View File

@@ -8,7 +8,7 @@ import { componentToSlot, enforceUnit, tryCall } from '../utils'
import type { FunctionalComponent, UnwrapNestedRefs, VNode } from 'vue'
import type { TableV2RowCellRenderParam } from '../components'
import type { UseNamespaceReturn } from '@element-plus/hooks'
import type { Translator, UseNamespaceReturn } from '@element-plus/hooks'
import type { UseTableReturn } from '../use-table'
import type { TableV2Props } from '../table'
@@ -19,6 +19,7 @@ type CellRendererProps = TableV2RowCellRenderParam &
> &
UnwrapNestedRefs<Pick<UseTableReturn, 'expandedRowKeys'>> & {
ns: UseNamespaceReturn
t: Translator
}
const CellRenderer: FunctionalComponent<CellRendererProps> = (
@@ -36,6 +37,7 @@ const CellRenderer: FunctionalComponent<CellRendererProps> = (
style,
expandedRowKeys,
ns,
t,
// derived props
cellProps: _cellProps,
expandColumnKey,
@@ -103,6 +105,9 @@ const CellRenderer: FunctionalComponent<CellRendererProps> = (
class={[ns.e('expand-icon'), ns.is('expanded', expanded)]}
size={iconSize}
expanded={expanded}
ariaLabel={t(
expanded ? 'el.table.collapseRowLabel' : 'el.table.expandRowLabel'
)}
style={iconStyle}
expandable
/>

View File

@@ -6,7 +6,7 @@ import { placeholderSign } from '../private'
import { componentToSlot, enforceUnit, tryCall } from '../utils'
import type { FunctionalComponent, UnwrapNestedRefs } from 'vue'
import type { UseNamespaceReturn } from '@element-plus/hooks'
import type { Translator, UseNamespaceReturn } from '@element-plus/hooks'
import type { TableV2HeaderRowCellRendererParams } from '../components'
import type { UseTableReturn } from '../use-table'
import type { TableV2Props } from '../table'
@@ -15,13 +15,14 @@ export type HeaderCellRendererProps = TableV2HeaderRowCellRendererParams &
UnwrapNestedRefs<Pick<UseTableReturn, 'onColumnSorted'>> &
Pick<TableV2Props, 'sortBy' | 'sortState' | 'headerCellProps'> & {
ns: UseNamespaceReturn
t: Translator
}
const HeaderCellRenderer: FunctionalComponent<HeaderCellRendererProps> = (
props,
{ slots }
) => {
const { column, ns, style, onColumnSorted } = props
const { column, ns, t, style, onColumnSorted } = props
const cellStyle = enforceUnit(style)
@@ -56,7 +57,7 @@ const HeaderCellRenderer: FunctionalComponent<HeaderCellRendererProps> = (
*/
const { sortBy, sortState, headerCellProps } = props
let sorting: boolean, sortOrder: SortOrder
let sorting: boolean, sortOrder: SortOrder, ariaSort: string | undefined
if (sortState) {
const order = sortState[column.key!]
sorting = Boolean(oppositeOrderMap[order])
@@ -65,6 +66,13 @@ const HeaderCellRenderer: FunctionalComponent<HeaderCellRendererProps> = (
sorting = column.key === sortBy.key
sortOrder = sorting ? sortBy.order : SortOrder.ASC
}
if (sortOrder === SortOrder.ASC) {
ariaSort = 'ascending'
} else if (sortOrder === SortOrder.DESC) {
ariaSort = 'descending'
} else {
ariaSort = undefined
}
const cellKls = [
ns.e('header-cell'),
@@ -77,6 +85,7 @@ const HeaderCellRenderer: FunctionalComponent<HeaderCellRendererProps> = (
const cellWrapperProps = {
...tryCall(headerCellProps, props),
onClick: column.sortable ? onColumnSorted : undefined,
ariaSort: sortable ? ariaSort : undefined,
class: cellKls,
style: cellStyle,
['data-key']: column.key,
@@ -91,6 +100,7 @@ const HeaderCellRenderer: FunctionalComponent<HeaderCellRendererProps> = (
<SortIcon
class={[ns.e('sort-icon'), sorting && ns.is('sorting')]}
sortOrder={sortOrder}
ariaLabel={t('el.table.sortLabel', { column: column.title || '' })}
/>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { defineComponent, provide, unref } from 'vue'
import { useNamespace } from '@element-plus/hooks'
import { useLocale, useNamespace } from '@element-plus/hooks'
import { useTable } from './use-table'
import { TableV2InjectionKey } from './tokens'
import { tableV2Props } from './table'
@@ -32,6 +32,7 @@ const TableV2 = defineComponent({
props: tableV2Props,
setup(props, { slots, expose }) {
const ns = useNamespace('table-v2')
const { t } = useLocale()
const {
columnsStyles,
@@ -238,6 +239,7 @@ const TableV2 = defineComponent({
rowKey,
expandedRowKeys: unref(expandedRowKeys),
ns,
t,
}
const tableHeaderProps = {
@@ -249,6 +251,7 @@ const TableV2 = defineComponent({
const tableHeaderCellProps = {
ns,
t,
sortBy,
sortState,

View File

@@ -228,6 +228,7 @@ export default defineComponent({
key: `${column.id}-thead`,
rowspan: column.rowSpan,
scope: column.colSpan > 1 ? 'colgroup' : 'col',
ariaSort: column.sortable ? column.order : undefined,
style: getHeaderCellStyle(
rowIndex,
cellIndex,

View File

@@ -165,6 +165,7 @@
}
@include e('sort-icon') {
@include reset-button();
transition:
opacity,
display getCssVar('transition-duration', '');
@@ -172,7 +173,7 @@
display: none;
@include when(sorting) {
display: block;
display: flex;
opacity: 1;
}
}
@@ -201,8 +202,8 @@
}
@include e('expand-icon') {
@include reset-button();
margin: 0 4px;
cursor: pointer;
user-select: none;
svg {