diff --git a/packages/storybook/stories/va-table-uswds.stories.tsx b/packages/storybook/stories/va-table-uswds.stories.tsx index 7bd8dae82e..5d120b39b9 100644 --- a/packages/storybook/stories/va-table-uswds.stories.tsx +++ b/packages/storybook/stories/va-table-uswds.stories.tsx @@ -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'; @@ -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'); + }, 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 ( -
+
{/* Force re-render by wrapping in a div with changing key */}
- + {columns.map((col, index) => ( {col} diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index c0df5508c2..9544628813 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -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. */ @@ -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 */ @@ -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. */ @@ -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 */ @@ -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. */ @@ -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 */ @@ -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. */ @@ -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 */ diff --git a/packages/web-components/src/components/va-table/va-table-inner/test/va-table-inner.e2e.ts b/packages/web-components/src/components/va-table/va-table-inner/test/va-table-inner.e2e.ts index 6f9a2db384..57be1979e9 100644 --- a/packages/web-components/src/components/va-table/va-table-inner/test/va-table-inner.e2e.ts +++ b/packages/web-components/src/components/va-table/va-table-inner/test/va-table-inner.e2e.ts @@ -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(` + + ${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 () => { diff --git a/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.scss b/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.scss index 517e97d99c..a83e805cec 100644 --- a/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.scss +++ b/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.scss @@ -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, @@ -53,10 +55,6 @@ top: 50%; transform: translate(0, -50%); text-align: center; - - &:focus { - outline: 2px solid var(--vads-color-action-focus-on-light); - } } } @@ -70,12 +68,14 @@ } caption { - text-align: left; - padding: 0 0 0.313rem; - font-weight: 700; - font-size: 1.25rem; - font-family: var(--font-serif); - margin-bottom: 0.75rem; + margin: 0.25rem; + #summary { + display: block; + font-weight: normal; + } + &:focus { + @include focus-style; + } } @media screen and (max-width: $medium-screen) { diff --git a/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.tsx b/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.tsx index 792c9dd7db..6651e5559d 100644 --- a/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.tsx +++ b/packages/web-components/src/components/va-table/va-table-inner/va-table-inner.tsx @@ -2,6 +2,7 @@ import { Component, Element, Prop, + Watch, h, Event, EventEmitter, @@ -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 */ @@ -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; @@ -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'); @@ -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, @@ -409,7 +450,13 @@ export class VaTableInner { return (
- {tableTitle && } + {tableTitle && } {this.makeRow(0)}{this.getBodyRows()}
{tableTitle} this.captionRef = el}> + {tableTitle}{tableTitleSummary ? ` ${tableTitleSummary}` : ''} + +
diff --git a/packages/web-components/src/components/va-table/va-table.tsx b/packages/web-components/src/components/va-table/va-table.tsx index 9245d1b9a0..d59050d8a6 100644 --- a/packages/web-components/src/components/va-table/va-table.tsx +++ b/packages/web-components/src/components/va-table/va-table.tsx @@ -6,6 +6,7 @@ import { h, State, Prop, + Watch, Listen, } from '@stencil/core'; @@ -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 */ @@ -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. */ @@ -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); } @@ -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); diff --git a/packages/web-components/src/mixins/focus.scss b/packages/web-components/src/mixins/focus.scss new file mode 100644 index 0000000000..c67d047941 --- /dev/null +++ b/packages/web-components/src/mixins/focus.scss @@ -0,0 +1,5 @@ +@mixin focus-style { + outline: 2px solid var(--vads-color-action-focus-on-light); + outline-offset: 2px; + z-index: 2; +} \ No newline at end of file