Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/chatty-badgers-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@sl-design-system/tag': patch
---

Accessibility improvements to `<sl-tag>` and `<sl-tag-list>`:

- The remove button now has a proper accessible label ("Remove tag 'X'") instead of being `aria-hidden`
- The remove button uses `aria-disabled` instead of `disabled`, keeping it keyboard-reachable when the tag is disabled
- Focus is delegated to the remove button via `delegatesFocus`; `:state(focus-visible)` tracks focus for styling
- Overflow tooltip `aria-describedby` is now on the label part instead of the host
- `<sl-tag-list>` correctly sets `role="listitem"` on each tag
5 changes: 5 additions & 0 deletions .changeset/stupid-cougars-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/locales': patch
---

Replace `sl.tag.removalInstructions` with `sl.tag.remove`, a parameterized string for the remove button label ("Remove tag 'X'").
3 changes: 3 additions & 0 deletions packages/components/tag/src/tag-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ declare global {
* </sl-tag-list>
* ```
*
* @customElement sl-tag-list
*
* @slot default - The place for tags.
*/
@localized()
Expand Down Expand Up @@ -337,6 +339,7 @@ export class TagList extends ScopedElementsMixin(LitElement) {
);

this.tags.forEach(tag => {
tag.role = 'listitem';
tag.size = this.size;
tag.variant = this.variant;
tag.setAttribute('role', 'listitem');
Expand Down
12 changes: 6 additions & 6 deletions packages/components/tag/src/tag.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
}
}

:host([removable]) slot {
:host([removable]) [part='label'] {
border-inline-end: var(--sl-size-borderWidth-default) solid var(--_br-color);
}

:host([size='lg']) {
slot {
[part='label'] {
padding: calc(var(--sl-size-100) - var(--sl-size-borderWidth-default)) var(--sl-size-150);
}

Expand All @@ -47,21 +47,20 @@
--_br-color: var(--sl-color-border-disabled);

color: var(--sl-color-foreground-disabled);
pointer-events: none;

slot,
[part='label'],
button {
background: var(--sl-color-background-disabled);
}
}

:host(:focus-visible) {
:host(:state(focus-visible)) {
outline-color: var(--sl-color-border-focused);
position: relative;
z-index: 1; // Make sure the focus ring is above other elements
}

slot {
[part='label'] {
background: var(--_bg-color);
display: block;
flex: 1;
Expand All @@ -84,6 +83,7 @@ button {
flex-shrink: 0;
inline-size: calc(var(--sl-size-300) - var(--sl-size-borderWidth-default) * 2);
justify-content: center;
outline: 0;
padding: 0;

@media (prefers-reduced-motion: no-preference) {
Expand Down
70 changes: 46 additions & 24 deletions packages/components/tag/src/tag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('sl-tag', () => {
el = await fixture(html`<sl-tag>My label</sl-tag>`);
});

it('should not have an explicit', () => {
it('should not have an explicit size', () => {
expect(el).not.to.have.attribute('size');
expect(el.size).to.be.undefined;
});
Expand Down Expand Up @@ -47,63 +47,84 @@ describe('sl-tag', () => {
el.removable = true;
await el.updateComplete;

expect(el).to.have.attribute('removable');
expect(el.renderRoot.querySelector('button')).to.exist;
});

it('should not have a tabindex', () => {
expect(el).not.to.have.attribute('tabindex');
it('should not have a tooltip', async () => {
el.focus();
await el.updateComplete;

expect(el.renderRoot.querySelector('[part="label"]')).not.to.have.attribute('aria-describedby');
expect(el.renderRoot.querySelector('sl-tooltip')).not.to.exist;
});

it('should not have a tooltip', async () => {
it('should not be focusable', async () => {
el.focus();
await el.updateComplete;

expect(el).not.to.have.attribute('aria-describedby');
expect(el).not.to.match(':focus');
expect(el).not.to.match(':state(focus-visible)');
});
});

describe('removable', () => {
let button: HTMLButtonElement;

beforeEach(async () => {
el = await fixture(html`<sl-tag removable>My label</sl-tag>`);
button = el.renderRoot.querySelector('button')!;
});

it('should have an ARIA description indicating how to remove the tag', () => {
expect(el).to.have.attribute('aria-description', 'Press the delete or backspace key to remove this item');
it('should not have the focus-visible state', () => {
expect(el).not.to.match(':state(focus-visible)');
});

it('should have a tabindex of 0', () => {
expect(el).to.have.attribute('tabindex', '0');
it('should have the focus-visible state when focused', async () => {
el.focus();
await el.updateComplete;

expect(el).to.match(':state(focus-visible)');
});

it('should have a tabindex of -1 when disabled', async () => {
el.disabled = true;
it('should have a button', () => {
expect(button).to.exist;
});

it('should focus the button when the tag is focused', async () => {
el.focus();
await el.updateComplete;

expect(el).to.have.attribute('tabindex', '-1');
expect(button).to.match(':focus');
});

it('should have a button', () => {
expect(el.renderRoot.querySelector('button')).to.exist;
it('should have an accessible label on the remove button', () => {
expect(button).to.have.attribute('aria-label', "Remove tag 'My label'");
});

it('should hide the button for ARIA', () => {
expect(el.renderRoot.querySelector('button')).to.have.attribute('aria-hidden', 'true');
it('should mark the button as aria-disabled when the tag is disabled', async () => {
el.disabled = true;
await el.updateComplete;

expect(button).to.have.attribute('aria-disabled', 'true');
});

it('should not be be removed when it is disabled and remove button is clicked', async () => {
el.setAttribute('disabled', '');
it('should not be removed when it is disabled and remove button is clicked', async () => {
const onRemove = spy(el, 'remove');

el.disabled = true;
await el.updateComplete;

el.renderRoot.querySelector('button')?.click();
button.click();
await el.updateComplete;

expect(el).to.exist;
expect(onRemove).not.to.have.been.called;
});

it('should be removed when the button is clicked using the keyboard', async () => {
const onRemove = spy(el, 'remove');

el.renderRoot.querySelector('button')?.focus();
el.focus();
await userEvent.keyboard('{Enter}');

expect(onRemove).to.have.been.calledOnce;
Expand Down Expand Up @@ -131,7 +152,7 @@ describe('sl-tag', () => {
const onRemove = spy();

el.addEventListener('sl-remove', onRemove);
el.renderRoot.querySelector('button')?.click();
button.click();
await el.updateComplete;

expect(onRemove).to.have.been.calledOnce;
Expand All @@ -150,9 +171,10 @@ describe('sl-tag', () => {
el.focus();
await el.updateComplete;

expect(el).to.have.attribute('aria-describedby');
const label = el.renderRoot.querySelector('[part="label"]')!;
expect(label).to.have.attribute('aria-describedby');

const tooltip = document.getElementById(el.getAttribute('aria-describedby')!);
const tooltip = el.renderRoot.querySelector('sl-tooltip');
expect(tooltip).to.exist;
expect(tooltip).to.have.trimmed.text('My label is very long');
});
Expand Down
17 changes: 15 additions & 2 deletions packages/components/tag/src/tag.stories.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import '../register.js';
import { type Tag } from './tag.js';

Expand Down Expand Up @@ -44,7 +43,7 @@ export default {
?disabled=${disabled}
?removable=${removable}
size=${ifDefined(size)}
style=${styleMap({ maxWidth })}
style=${ifDefined(maxWidth ? `max-inline-size: ${maxWidth}` : undefined)}
variant=${ifDefined(variant)}
>
${label}
Expand Down Expand Up @@ -73,8 +72,22 @@ export const Overflow: Story = {
}
};

export const OverflowRemovable: Story = {
args: {
...Overflow.args,
removable: true
}
};

export const Removable: Story = {
args: {
removable: true
}
};

export const RemovableDisabled: Story = {
args: {
disabled: true,
removable: true
}
};
Loading
Loading