mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
feat(components): [table-v2] accessibility enhancement (#22983)
* feat(components): [table-v2] accessibility enhancement * chore: add test * chore: formatted
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user