Skip to content
This repository is currently being migrated. It's locked while the migration is in progress.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions packages/storybook/stories/va-table-uswds.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { getWebComponentDocs, propStructure, StoryDocs } from './wc-helpers';
import { VaPagination } from '@department-of-veterans-affairs/component-library/dist/react-bindings';

Expand Down Expand Up @@ -260,19 +260,33 @@ const Pagination = args => {

const [currentData, setCurrentData] = useState(paginate(rows, MAX_ROWS, 1));
const [currentPage, setCurrentPage] = useState(1);
const tableRef = useRef(null);

function onPageChange(page) {
setCurrentData(paginate(rows, MAX_ROWS, page));
setCurrentPage(page);

// Focus caption with pagination summary after page changes
setTimeout(() => {
const main = tableRef.current;
const vaTable = main?.querySelector('va-table');
vaTable?.setAttribute('set-caption-focus', 'true');
Copy link
Copy Markdown
Contributor Author

@jamigibbs jamigibbs Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Howdy @department-of-veterans-affairs/platform-design-system-a11y reviewer! 👋🏼 I added a prop for focusing the caption element as-needed and it appears to be functioning as intended but my VO testing resulted in some duplicate readouts when I integrated it with this pagination example in Storybook.

Is it possible that this isn't the correct a11y approach for using pagination with va-table?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look - I heard it duplicated in VO too (but sometimes that just.. happens). I'm going to listen in NVDA & JAWS too, then come back and see if this might just be a VO quirk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jeana-adhoc! Based on our convo, I've updated the description of the PR with a11y notes to explain our approach. Feel free to edit or add if you'd like!

}, 50);
}

const numPages = Math.ceil(rows.length / MAX_ROWS);

// Dynamic pagination summary
const totalRows = rows.length;
const startRow = (currentPage - 1) * MAX_ROWS + 1;
const endRow = Math.min(currentPage * MAX_ROWS, totalRows);
const paginationSummary = `Showing ${startRow}-${endRow} of ${totalRows} payments`;

return (
<main>
<main ref={tableRef}>
{/* Force re-render by wrapping in a div with changing key */}
<div key={`table-wrapper-${currentPage}`}>
<va-table table-title={tableTitle} scrollable={scrollable} mono-font-cols='1' right-align-cols='1'>
<va-table table-title={tableTitle} table-title-summary={paginationSummary} scrollable={scrollable} mono-font-cols='1' right-align-cols='1'>
<va-table-row>
{columns.map((col, index) => (
<span key={`table-header-${index}`}>{col}</span>
Expand Down
32 changes: 32 additions & 0 deletions packages/web-components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,10 @@ export namespace Components {
* When active, the table can be horizontally scrolled and is focusable
*/
"scrollable"?: boolean;
/**
* Set focus on the table caption element
*/
"setCaptionFocus"?: boolean;
/**
* If true, the table is sortable. To use a raw sort value for a cell, add a data-sort-value attribute to the span element.
*/
Expand All @@ -1978,6 +1982,10 @@ export namespace Components {
* The title of the table
*/
"tableTitle"?: string;
/**
* Additional context for the table. For example, pagination information. e.g. "Showing 1-10 of 13 charges"
*/
"tableTitleSummary"?: string;
/**
* The type of table
*/
Expand Down Expand Up @@ -2010,6 +2018,10 @@ export namespace Components {
* When active, the table can be horizontally scrolled and is focusable
*/
"scrollable"?: boolean;
/**
* Set focus on the table caption element
*/
"setCaptionFocus"?: boolean;
/**
* If true, the table is sortable. To use a raw sort value for a cell, add a data-sort-value attribute to the span element.
*/
Expand All @@ -2026,6 +2038,10 @@ export namespace Components {
* The title of the table
*/
"tableTitle": string;
/**
* Additional context for the table. For example, pagination information. e.g. "Showing 1-10 of 13 charges"
*/
"tableTitleSummary"?: string;
/**
* The type of table to be used
*/
Expand Down Expand Up @@ -6165,6 +6181,10 @@ declare namespace LocalJSX {
* When active, the table can be horizontally scrolled and is focusable
*/
"scrollable"?: boolean;
/**
* Set focus on the table caption element
*/
"setCaptionFocus"?: boolean;
/**
* If true, the table is sortable. To use a raw sort value for a cell, add a data-sort-value attribute to the span element.
*/
Expand All @@ -6181,6 +6201,10 @@ declare namespace LocalJSX {
* The title of the table
*/
"tableTitle"?: string;
/**
* Additional context for the table. For example, pagination information. e.g. "Showing 1-10 of 13 charges"
*/
"tableTitleSummary"?: string;
/**
* The type of table
*/
Expand Down Expand Up @@ -6217,6 +6241,10 @@ declare namespace LocalJSX {
* When active, the table can be horizontally scrolled and is focusable
*/
"scrollable"?: boolean;
/**
* Set focus on the table caption element
*/
"setCaptionFocus"?: boolean;
/**
* If true, the table is sortable. To use a raw sort value for a cell, add a data-sort-value attribute to the span element.
*/
Expand All @@ -6233,6 +6261,10 @@ declare namespace LocalJSX {
* The title of the table
*/
"tableTitle"?: string;
/**
* Additional context for the table. For example, pagination information. e.g. "Showing 1-10 of 13 charges"
*/
"tableTitleSummary"?: string;
/**
* The type of table to be used
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,56 @@ describe('va-table-inner', () => {
it('adds a caption', async () => {
const page = await newE2EPage();
await page.setContent(makeTable());
const visibleText = await page.find('va-table-inner >>> caption > span[aria-hidden="true"]');
expect(visibleText.textContent).toEqual('this is a caption');
});

it('sets tabindex on caption when set-caption-focus is true on initial render', async () => {
const page = await newE2EPage();
await page.setContent(makeTable({ 'set-caption-focus': 'true' }));
await page.waitForChanges();

const caption = await page.find('va-table-inner >>> caption');
expect(caption.innerHTML).toEqual('this is a caption');
expect(caption.getAttribute('tabindex')).toEqual('-1');
});

it('sets tabindex on caption when set-caption-focus attribute is set dynamically', async () => {
const page = await newE2EPage();
await page.setContent(makeTable());
await page.waitForChanges();

// Set the attribute dynamically
await page.evaluate(() => {
const vaTable = document.querySelector('va-table');
vaTable?.setAttribute('set-caption-focus', 'true');
});
await page.waitForChanges();

const caption = await page.find('va-table-inner >>> caption');
expect(caption.getAttribute('tabindex')).toEqual('-1');
});

it('removes tabindex from caption after blur', async () => {
const page = await newE2EPage();
await page.setContent(`
<button id="other-element">Other</button>
${makeTable({ 'set-caption-focus': 'true' })}
`);
await page.waitForChanges();

const caption = await page.find('va-table-inner >>> caption');
expect(caption.getAttribute('tabindex')).toEqual('-1');

// Trigger blur by dispatching blur event on the caption
await page.evaluate(() => {
const vaTableInner = document.querySelector('va-table-inner');
const caption = vaTableInner?.shadowRoot?.querySelector('caption');
caption?.dispatchEvent(new FocusEvent('blur'));
});
await page.waitForChanges();

const tabindexAfterBlur = await caption.getAttribute('tabindex');
expect(tabindexAfterBlur).toBeNull();
});

it('renders a table with the proper number of rows and columns', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
@use 'usa-table/src/styles/usa-table';
@use 'uswds-helpers/src/styles/usa-sr-only';
@use "~@department-of-veterans-affairs/css-library/dist/tokens/scss/variables" as *;

@import '~@department-of-veterans-affairs/css-library/dist/stylesheets/utilities.css';
@import '../../../mixins/focus';

:host {
td slot::slotted(span:empty)::before,
Expand Down Expand Up @@ -53,10 +55,6 @@
top: 50%;
transform: translate(0, -50%);
text-align: center;

&:focus {
outline: 2px solid var(--vads-color-action-focus-on-light);
}
}
}

Expand All @@ -70,12 +68,14 @@
}

caption {
text-align: left;
padding: 0 0 0.313rem;
font-weight: 700;
font-size: 1.25rem;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these styles are carry overs from the v1 (non-USWDS) version of the component so they are now unnecessary. The caption is now styled directly from the USWDS table class .usa-table caption:

Image

font-family: var(--font-serif);
margin-bottom: 0.75rem;
margin: 0.25rem;
#summary {
display: block;
font-weight: normal;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi again, @department-of-veterans-affairs/platform-design-system-designers reviewer! 👋🏼

We have an opportunity here to style caption summary however we'd like. This is my best guess. Let me know if you would prefer something else.

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamigibbs I think the styling is good. I'm not sure if that's the best placement for the results text though? Should it be closer to the pagination controls? 🤔 That might complicat focus order though. This is just a hunch, but I can do some research if necessary. Before doing that, I wonder if this be more of an accessibility question though. @amyleadem @jeana-adhoc Your thoughts?

Here's a quick mock up moving the results total to be near the pagination controls:

image (It will need a little more space if we go this direction.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @jamigibbs initial placement makes sense. It's similar to how we manage search results.

image

After triggering a pagination change, we need to announce that change and send focus somewhere, and we wouldn't want to focus directly above the pagination.

@amyleadem Would be curious if you have other thoughts about this.

}
&:focus {
@include focus-style;
}
}

@media screen and (max-width: $medium-screen) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Component,
Element,
Prop,
Watch,
h,
Event,
EventEmitter,
Expand Down Expand Up @@ -35,6 +36,12 @@ export class VaTableInner {
*/
@Prop() tableTitle: string;

/**
* Additional context for the table. For example, pagination information.
* e.g. "Showing 1-10 of 13 charges"
*/
@Prop() tableTitleSummary?: string;

/*
* The number of rows in the table
*/
Expand Down Expand Up @@ -85,6 +92,14 @@ export class VaTableInner {
*/
@Prop() monoFontCols?: string;

/**
* Set focus on the table caption element
*/
@Prop() setCaptionFocus?: boolean = false;

// Reference to the caption element for focus management
private captionRef: HTMLElement;

// Internal 'holder' for the array of columns to right-align, updated in componentWillRender
colsToAlign: Array<number>;

Expand Down Expand Up @@ -119,6 +134,32 @@ export class VaTableInner {
}
}

componentDidLoad() {
// Handle initial setCaptionFocus if true on first render
if (this.setCaptionFocus) {
this.focusCaption();
}
}

@Watch('setCaptionFocus')
handleSetCaptionFocusChange(newValue: boolean) {
if (newValue) {
this.focusCaption();
}
}

private focusCaption() {
if (this.captionRef) {
this.captionRef.setAttribute('tabindex', '-1');
this.captionRef.focus();
// Remove tabindex after focus leaves the caption
this.captionRef.addEventListener('blur', () => {
this.captionRef.removeAttribute('tabindex');
this.setCaptionFocus = false;
}, { once: true });
}
}

fireSort(e: Event) {
const target = e.currentTarget as HTMLElement;
const th = target.closest('th');
Expand Down Expand Up @@ -394,7 +435,7 @@ export class VaTableInner {
}

render() {
const { tableTitle, tableType, stacked, scrollable, striped, fullWidth } =
const { tableTitle, tableTitleSummary, tableType, stacked, scrollable, striped, fullWidth } =
this;
const containerClasses = classnames({
'usa-table-container--scrollable': scrollable,
Expand All @@ -409,7 +450,13 @@ export class VaTableInner {
return (
<div tabIndex={scrollable ? 0 : null} class={containerClasses}>
<table class={tableClasses}>
{tableTitle && <caption>{tableTitle}</caption>}
{tableTitle && <caption ref={(el) => this.captionRef = el}>
<span class="usa-sr-only">{tableTitle}{tableTitleSummary ? ` ${tableTitleSummary}` : ''}</span>
<span aria-hidden="true">
{tableTitle}
{tableTitleSummary && <span id="summary">{tableTitleSummary}</span>}
</span>
</caption>}
<thead>{this.makeRow(0)}</thead>
<tbody id="va-table-body">{this.getBodyRows()}</tbody>
</table>
Expand Down
28 changes: 28 additions & 0 deletions packages/web-components/src/components/va-table/va-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
h,
State,
Prop,
Watch,
Listen,
} from '@stencil/core';

Expand Down Expand Up @@ -34,6 +35,12 @@ export class VaTable {
*/
@Prop() tableTitle?: string;

/**
* Additional context for the table. For example, pagination information.
* e.g. "Showing 1-10 of 13 charges"
*/
@Prop() tableTitleSummary?: string;

/**
* The type of table
*/
Expand Down Expand Up @@ -75,6 +82,19 @@ export class VaTable {
*/
@Prop() monoFontCols?: string;

/**
* Set focus on the table caption element
*/
@Prop() setCaptionFocus?: boolean = false;

@Watch('setCaptionFocus')
handleSetCaptionFocusChange(newValue: boolean) {
const vaTableInner = this.el.querySelector('va-table-inner');
if (vaTableInner) {
vaTableInner.setAttribute('set-caption-focus', String(newValue));
}
}

/**
* Text to display in empty cells. Needed for screen readers to announce empty cells.
*/
Expand Down Expand Up @@ -190,6 +210,10 @@ export class VaTable {
vaTable.setAttribute('table-title', this.tableTitle);
}

if (this.tableTitleSummary) {
vaTable.setAttribute('table-title-summary', this.tableTitleSummary);
}

if (this.tableType) {
vaTable.setAttribute('table-type', this.tableType);
}
Expand All @@ -206,6 +230,10 @@ export class VaTable {
vaTable.setAttribute('mono-font-cols', this.monoFontCols);
}

if (this.setCaptionFocus) {
vaTable.setAttribute('set-caption-focus', String(this.setCaptionFocus));
}

//make a fragment containing all the cells, one for each slot
const frag = this.makeFragment();
vaTable.appendChild(frag);
Expand Down
5 changes: 5 additions & 0 deletions packages/web-components/src/mixins/focus.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@mixin focus-style {
outline: 2px solid var(--vads-color-action-focus-on-light);
outline-offset: 2px;
z-index: 2;
}
Copy link
Copy Markdown
Contributor Author

@jamigibbs jamigibbs Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I created this Sass mixin because the single stylesheet approach for focus style has become unwieldy. I am going to create a follow-up ticket for us to look at switching to using a direct mixin in the other places the original stylesheet is referencing.

Loading