Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0545f1f
fix(grid): Add aria-selected attribute handling for single select rows
anna-lach May 27, 2026
73f7e26
Changes after Copilot review
anna-lach May 28, 2026
c6b7e60
Merge remote-tracking branch 'origin/main' into fix/2896-grid-selecti…
anna-lach May 28, 2026
b8dd60b
remove unnecessary formatting changes
anna-lach May 28, 2026
dc980f4
changeset
anna-lach May 28, 2026
d8c9f2f
Changes after Copilot review
anna-lach May 28, 2026
50d9650
Formatting changes
anna-lach May 28, 2026
7eef9fe
Merge remote-tracking branch 'origin/main' into fix/2896-grid-selecti…
anna-lach Jun 15, 2026
02f10eb
fix(grid): announce row selection state (with announcer) for accessib…
anna-lach Jun 15, 2026
c5ac74b
fix(grid): enhance row selection announcements with forced updates fo…
anna-lach Jun 16, 2026
b9bf026
update row activation announcements for improved accessibility, trans…
anna-lach Jun 16, 2026
2c23d4f
fix(grid): add accessibility attributes to row activation buttons (st…
anna-lach Jun 16, 2026
db6adc6
Merge remote-tracking branch 'origin/main' into fix/2896-grid-selecti…
anna-lach Jun 16, 2026
52d54ba
Merge remote-tracking branch 'origin/main' into fix/2896-grid-selecti…
anna-lach Jun 17, 2026
4e8962f
Changes after Copilot review
anna-lach Jun 17, 2026
bb65092
Changes after Copilot review
anna-lach Jun 17, 2026
3ea96c3
Changeset
anna-lach Jun 17, 2026
55f182b
Changeset update
anna-lach Jun 17, 2026
193220e
remove unnecessay change
anna-lach Jun 17, 2026
3cc6f8f
update urgency level in grid announcements from 'assertive' to 'polite'
anna-lach Jun 17, 2026
e1ac05f
Changes after Copilot review
anna-lach Jun 17, 2026
e099015
update grid row announcements to reflect correct row numbers
anna-lach Jun 17, 2026
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
5 changes: 5 additions & 0 deletions .changeset/short-nails-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/announcer': minor
---

Added a `force` option to the `announce()` function to allow repeated announcements of the same message (e.g. when re-focusing an active row in the grid).
11 changes: 11 additions & 0 deletions .changeset/vast-wombats-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@sl-design-system/grid': patch
---

Accessibility improvements for row activation and selection:

- Added `aria-current` to the active/selected row in activate and single-select modes.
- The grid now announces row activation and deactivation to screen readers.
- When you focus an already active row with the keyboard, the grid reannounces it (using `force`).

**Note:** If you use a button to trigger row activation, you should add `aria-pressed` and `aria-description` to it yourself. The grid does not set these for you. See the `'Activate'` story for an example of how to do this.
7 changes: 5 additions & 2 deletions packages/components/announcer/src/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
*
* @param message - The message to send to the live aria.
* @param urgency - The urgency of the message. Default is 'polite'.
* @param force - If true, bypasses deduplication and always announces the message.
*/
export function announce(message: string, urgency?: 'polite' | 'assertive'): void {
document.body.dispatchEvent(new CustomEvent('sl-announce', { detail: { message, urgency } }));
export function announce(message: string, urgency?: 'polite' | 'assertive', force?: boolean): void {
document.body.dispatchEvent(
new CustomEvent('sl-announce', { detail: { message, urgency, ...(force ? { force } : {}) } })
);
}
34 changes: 25 additions & 9 deletions packages/components/announcer/src/announcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ declare global {
}
}

export type SlAnnounceEvent = CustomEvent<{ message: string; urgency?: 'polite' | 'assertive' }>;
export type SlAnnounceEvent = CustomEvent<{
message: string;
urgency?: 'polite' | 'assertive';
force?: boolean;
}>;

/**
* Utility that serves as a recipient for all live-aria notifications and supplies them for
Expand All @@ -30,6 +34,9 @@ export class Announcer extends LitElement {

#events = new EventsController(this, {});

/** Counter used to make forced announcements unique for screen reader deduplication. */
#forceCounter = 0;

override connectedCallback(): void {
super.connectedCallback();

Expand All @@ -48,15 +55,24 @@ export class Announcer extends LitElement {
`[aria-live="${event.detail.urgency || 'polite'}"]`
);

// make sure the message is not already in the container
if (container?.textContent?.indexOf(event.detail.message) === -1) {
const messageNode = document.createElement('li');
messageNode.innerText = event.detail.message;
const messageNode = document.createElement('li');

container?.appendChild(messageNode);
setTimeout(() => {
messageNode.remove();
}, 500);
if (event.detail.force) {
this.#forceCounter++;
// Append invisible zero width spaces to make each message unique for screen readers.
// We use % 4 so the suffix cycles through 1 – 4 characters, which is enough to avoid
// duplicates while messages are still in the live region (removed after 500ms).
messageNode.innerText = event.detail.message + '\u200B'.repeat((this.#forceCounter % 4) + 1);
} else if (container?.textContent?.indexOf(event.detail.message) === -1) {
// make sure the message is not already in the container
messageNode.innerText = event.detail.message;
} else {
return;
Comment thread
anna-lach marked this conversation as resolved.
}

container?.appendChild(messageNode);
setTimeout(() => {
messageNode.remove();
}, 500);
}
}
1 change: 1 addition & 0 deletions packages/components/grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"test": "echo \"Error: run tests from monorepo root.\" && exit 1"
},
"dependencies": {
"@sl-design-system/announcer": "^0.0.8",
Comment thread
anna-lach marked this conversation as resolved.
"@sl-design-system/button": "^2.1.0",
"@sl-design-system/checkbox": "^2.1.10",
"@sl-design-system/data-source": "^0.4.0",
Expand Down
173 changes: 167 additions & 6 deletions packages/components/grid/src/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ describe('sl-grid', () => {

expect(rowIndices).to.deep.equal(['1', '2']);
});

it('should not have aria-current when no row action or selection is configured', () => {
const rows = el.renderRoot.querySelectorAll<HTMLTableRowElement>('tbody tr');

rows.forEach(row => {
expect(row).not.to.have.attribute('aria-current');
});
});
});

describe('multiple select', () => {
Expand Down Expand Up @@ -418,6 +426,19 @@ describe('sl-grid', () => {
expect(el.dataSource?.selects).to.equal('single');
});

it('should set aria-current="true" on the selected row', async () => {
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
await new Promise(resolve => setTimeout(resolve));

const row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type'),
otherRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:nth-of-type(2)');

expect(row).to.have.attribute('aria-current', 'true');
expect(otherRow).not.to.have.attribute('aria-current');
});

it('should toggle the "selected" part of the row when clicking in the row', async () => {
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
Expand All @@ -429,7 +450,6 @@ describe('sl-grid', () => {
});
Comment thread
anna-lach marked this conversation as resolved.

it('should allow only one row to be selected at a time', async () => {
// Select first row
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
Expand All @@ -440,7 +460,6 @@ describe('sl-grid', () => {
);
expect(selectedRows).to.have.lengthOf(1);

// Select second row - should deselect first row
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:nth-of-type(2) td:last-of-type')
?.click();
Expand All @@ -451,17 +470,14 @@ describe('sl-grid', () => {
);
expect(selectedRows).to.have.lengthOf(1);

// Verify first row is no longer selected
const firstRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(firstRow?.part.contains('selected')).to.be.false;

// Verify second row is selected
const secondRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:nth-of-type(2)');
expect(secondRow?.part.contains('selected')).to.be.true;
});

Comment thread
anna-lach marked this conversation as resolved.
it('should deselect a row when clicking it again', async () => {
// Select a row
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
Expand All @@ -470,7 +486,6 @@ describe('sl-grid', () => {
let row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.true;

// Click again to deselect
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
Expand Down Expand Up @@ -586,6 +601,105 @@ describe('sl-grid', () => {
expect(onActiveRowChange.firstCall.args[0].detail).to.deep.equal(el.items!.at(1));
});

it('should set aria-current="true" on the active row', async () => {
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

const row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type'),
otherRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');

expect(row).to.have.attribute('aria-current', 'true');
expect(otherRow).not.to.have.attribute('aria-current');
});

it('should remove aria-current when deactivating', async () => {
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

let row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type');

expect(row).to.have.attribute('aria-current', 'true');

row?.click();
await new Promise(resolve => setTimeout(resolve));

row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type');

expect(row).not.to.have.attribute('aria-current');
});

it('should dispatch sl-announce event when activating a row', async () => {
const announceSpy = spy();
document.body.addEventListener('sl-announce', announceSpy);

el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(announceSpy).to.have.been.calledOnce;

const event = announceSpy.firstCall.args[0] as CustomEvent<{
message: string;
urgency: string;
}>;

expect(event.detail.message).to.equal('Row 3 activated');
expect(event.detail.urgency).to.equal('polite');

document.body.removeEventListener('sl-announce', announceSpy);
});

it('should dispatch sl-announce event when deactivating a row', async () => {
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

const announceSpy = spy();
document.body.addEventListener('sl-announce', announceSpy);
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(announceSpy).to.have.been.calledOnce;

const event = announceSpy.firstCall.args[0] as CustomEvent<{
message: string;
urgency: string;
}>;

expect(event.detail.message).to.equal('Row 3 deactivated');
expect(event.detail.urgency).to.equal('polite');

document.body.removeEventListener('sl-announce', announceSpy);
});

it('should dispatch sl-announce with force=true when focusing into an active row', async () => {
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

const tbody = el.renderRoot.querySelector('tbody')!;
tbody.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await new Promise(resolve => setTimeout(resolve));

const announceSpy = spy();
document.body.addEventListener('sl-announce', announceSpy);

const td = el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td');
td?.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await new Promise(resolve => setTimeout(resolve));

expect(announceSpy).to.have.been.calledOnce;

const event = announceSpy.firstCall.args[0] as CustomEvent<{
message: string;
urgency: string;
force: boolean;
}>;

expect(event.detail.message).to.equal('In activated row 2');
expect(event.detail.urgency).to.equal('assertive');
expect(event.detail.force).to.be.true;

document.body.removeEventListener('sl-announce', announceSpy);
});

it('should keep sticky active row cells opaque', async () => {
el = await fixture(html`
<sl-grid
Expand Down Expand Up @@ -718,6 +832,53 @@ describe('sl-grid', () => {
expect(toggleSpy).to.have.been.calledOnce;
expect(toggleSpy.firstCall.args[0]).to.have.property('data', el.items?.at(0));
});

it('should dispatch sl-announce event when selecting a row', async () => {
const announceSpy = spy();
document.body.addEventListener('sl-announce', announceSpy);

el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
await new Promise(resolve => setTimeout(resolve));

expect(announceSpy).to.have.been.calledOnce;

const event = announceSpy.firstCall.args[0] as CustomEvent<{
message: string;
urgency: string;
}>;
expect(event.detail.message).to.equal('Row 2 activated');
expect(event.detail.urgency).to.equal('polite');

document.body.removeEventListener('sl-announce', announceSpy);
});

it('should dispatch sl-announce event when deselecting a row', async () => {
el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
await new Promise(resolve => setTimeout(resolve));

const announceSpy = spy();
document.body.addEventListener('sl-announce', announceSpy);

el.renderRoot
.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')
?.click();
await new Promise(resolve => setTimeout(resolve));

expect(announceSpy).to.have.been.calledOnce;

const event = announceSpy.firstCall.args[0] as CustomEvent<{
message: string;
urgency: string;
}>;
expect(event.detail.message).to.equal('Row 2 deactivated');
expect(event.detail.urgency).to.equal('polite');

document.body.removeEventListener('sl-announce', announceSpy);
});
});

describe('bulk actions', () => {
Expand Down
Loading
Loading