Skip to content

fix(tag): keyboard/a11y for tag arrow navigation (#2419)#2490

Open
amir-ba wants to merge 5 commits into
telekom:mainfrom
amir-ba:copilot/issue-2419-scale-tag-keyboard-fix
Open

fix(tag): keyboard/a11y for tag arrow navigation (#2419)#2490
amir-ba wants to merge 5 commits into
telekom:mainfrom
amir-ba:copilot/issue-2419-scale-tag-keyboard-fix

Conversation

@amir-ba

@amir-ba amir-ba commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Implement roving tabindex and ARIA role for scale-tag so arrow keys move focus and screen readers announce tags. Add unit and e2e tests.

Copilot AI review requested due to automatic review settings April 28, 2026 10:35
@amir-ba amir-ba requested a review from maomaoZH as a code owner April 28, 2026 10:35

Copilot AI left a comment

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.

Pull request overview

This PR aims to add keyboard arrow-key navigation and improved accessibility semantics to scale-tag via a roving tabindex approach, along with updated unit/e2e coverage. It also bumps package versions to 3.0.0-beta.160 and updates changelogs accordingly.

Changes:

  • Add roving tabindex + arrow-key handling and ARIA attributes to scale-tag.
  • Add unit tests, e2e tests, and snapshot updates for the new tag behavior.
  • Version bump + changelog updates across multiple packages (beta.159 → beta.160).

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/components/src/components/tag/tag.tsx Implements roving tabindex + keyboard handling + ARIA attributes on the tag host.
packages/components/src/components/tag/tag.spec.ts Adds unit tests for new keyboard/ARIA behavior.
packages/components/src/components/tag/tag.e2e.ts Adds e2e tests for roving tabindex and arrow navigation across multiple tags.
packages/components/src/components/tag/snapshots/tag.spec.ts.snap Updates snapshots to reflect new host attributes.
packages/storybook-vue/package.json Bumps version and updates component dependency versions to beta.160.
packages/visual-tests/package.json Bumps version to beta.160.
packages/design-tokens/package.json Bumps version to beta.160.
packages/components/package.json Bumps version to beta.160.
packages/components-vue/package.json Bumps version to beta.160.
packages/components-react/package.json Bumps version to beta.160.
packages/components-angular/package.json Bumps version to beta.160.
lerna.json Bumps monorepo version to beta.160.
CHANGELOG.md Adds beta.160 release notes.
packages/components/CHANGELOG.md Adds beta.160 release notes for components package.
packages/components-angular/CHANGELOG.md Adds beta.160 version bump entry.
packages/components-react/CHANGELOG.md Adds beta.160 version bump entry.
packages/components-vue/CHANGELOG.md Adds beta.160 version bump entry.
packages/design-tokens/CHANGELOG.md Adds beta.160 version bump entry.
packages/storybook-vue/CHANGELOG.md Adds beta.160 release notes for storybook-vue.
packages/visual-tests/CHANGELOG.md Adds beta.160 release notes for visual-tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +59 to +79
this.initializeRovingTabindex();
}

componentWillUpdate() {}
disconnectedCallback() {}

private initializeRovingTabindex() {
// Find all sibling scale-tag elements
const siblings = this.getSiblingTags();
if (siblings.length === 0) return;

// Set first tag as focusable, others as not focusable
siblings.forEach((tag, index) => {
if (index === 0) {
tag.setAttribute('tabindex', '0');
} else {
tag.setAttribute('tabindex', '-1');
}
// Set ARIA attributes for accessibility
tag.setAttribute('role', 'option');
tag.setAttribute('aria-selected', 'false');

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The roving tabindex logic does not account for disabled tags: initializeRovingTabindex() and updateRoving() will include disabled tags in the navigation order, and the component still makes the host focusable. This is inconsistent with other components (e.g. tab-header removes role/tabindex when disabled) and can lead to focus landing on a disabled control. Consider skipping disabled tags when building the roving list and reflecting aria-disabled / removing tabindex when disabled is true.

Suggested change
this.initializeRovingTabindex();
}
componentWillUpdate() {}
disconnectedCallback() {}
private initializeRovingTabindex() {
// Find all sibling scale-tag elements
const siblings = this.getSiblingTags();
if (siblings.length === 0) return;
// Set first tag as focusable, others as not focusable
siblings.forEach((tag, index) => {
if (index === 0) {
tag.setAttribute('tabindex', '0');
} else {
tag.setAttribute('tabindex', '-1');
}
// Set ARIA attributes for accessibility
tag.setAttribute('role', 'option');
tag.setAttribute('aria-selected', 'false');
this.syncTagAccessibility(this.hostElement);
this.initializeRovingTabindex();
}
componentWillUpdate() {
this.syncTagAccessibility(this.hostElement);
this.initializeRovingTabindex();
}
disconnectedCallback() {}
private isTagDisabled(tag: HTMLElement): boolean {
return tag.hasAttribute('disabled') || !!(tag as any).disabled;
}
private syncTagAccessibility(tag: HTMLElement) {
if (this.isTagDisabled(tag)) {
tag.setAttribute('aria-disabled', 'true');
tag.removeAttribute('tabindex');
tag.removeAttribute('role');
tag.removeAttribute('aria-selected');
return;
}
tag.removeAttribute('aria-disabled');
tag.setAttribute('role', 'option');
tag.setAttribute('aria-selected', 'false');
}
private initializeRovingTabindex() {
const allSiblings = this.getSiblingTags();
if (allSiblings.length === 0) return;
allSiblings.forEach((tag) => this.syncTagAccessibility(tag));
const enabledSiblings = allSiblings.filter((tag) => !this.isTagDisabled(tag));
if (enabledSiblings.length === 0) return;
// Set first enabled tag as focusable, others as not focusable
enabledSiblings.forEach((tag, index) => {
if (index === 0) {
tag.setAttribute('tabindex', '0');
} else {
tag.setAttribute('tabindex', '-1');
}

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +102
private updateRoving(newFocusedTag: HTMLElement) {
const siblings = this.getSiblingTags();
siblings.forEach((tag) => {
if (tag === newFocusedTag) {
tag.setAttribute('tabindex', '0');
} else {
tag.setAttribute('tabindex', '-1');
}
});
// Focus the new tag
newFocusedTag.focus();

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

updateRoving() calls newFocusedTag.focus() on the custom element host. When href is set, the actual interactive element is the internal <a> (and for dismissable tags, there is also an internal <button>), so focusing only the host can create an extra tab stop and may not produce the expected activation semantics (e.g. Enter/Space on the focused element). Consider applying roving tabindex/focus to the real interactive target (anchor/button) or delegating focus into the shadow tree instead of focusing the host.

Suggested change
private updateRoving(newFocusedTag: HTMLElement) {
const siblings = this.getSiblingTags();
siblings.forEach((tag) => {
if (tag === newFocusedTag) {
tag.setAttribute('tabindex', '0');
} else {
tag.setAttribute('tabindex', '-1');
}
});
// Focus the new tag
newFocusedTag.focus();
private getRovingTarget(tag: HTMLElement): HTMLElement {
const interactiveTarget = tag.shadowRoot?.querySelector('a, button') as HTMLElement | null;
return interactiveTarget || tag;
}
private setRovingTabindex(tag: HTMLElement, isFocused: boolean) {
const target = this.getRovingTarget(tag);
const tabindex = isFocused ? '0' : '-1';
if (target === tag) {
tag.setAttribute('tabindex', tabindex);
return;
}
tag.setAttribute('tabindex', '-1');
target.setAttribute('tabindex', tabindex);
}
private updateRoving(newFocusedTag: HTMLElement) {
const siblings = this.getSiblingTags();
siblings.forEach((tag) => {
this.setRovingTabindex(tag, tag === newFocusedTag);
});
// Focus the new tag's actual interactive target
this.getRovingTarget(newFocusedTag).focus();

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +158
role="option"
aria-selected="false"

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

role="option" is being applied directly to <scale-tag> without providing the required listbox context (role="option" has required parent role listbox). In this codebase, role="option" is used within an element that has role="listbox" (e.g. dropdown-select.tsx sets role="listbox" on the container). Consider switching to a role that matches standalone usage (or making the parent container own the listbox role) to avoid invalid ARIA.

Suggested change
role="option"
aria-selected="false"

Copilot uses AI. Check for mistakes.
<Host
role="option"
aria-selected="false"
tabindex={0}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

<Host tabindex={0}> hard-codes the host as tabbable on every render. Since the roving logic updates tabindex via setAttribute(), any re-render (triggered by prop changes like disabled, color, etc.) will reset the host back to tabindex="0" and break the roving tabindex pattern (potentially making multiple tags tabbable again). Consider deriving tabindex from component state/props or removing tabindex from render() and managing it exclusively via the roving logic.

Suggested change
tabindex={0}

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +19
# [3.0.0-beta.160](https://github.com/telekom/scale/compare/v3.0.0-beta.159...v3.0.0-beta.160) (2026-04-24)


### Bug Fixes

* **date-picker:** change date picker contrast in dark mode ([#2467](https://github.com/telekom/scale/issues/2467)) ([8eed7ac](https://github.com/telekom/scale/commit/8eed7accd17518128b97d28dad02b1ca24b843b7))
* **style:** correct font format fallback ([#2481](https://github.com/telekom/scale/issues/2481)) ([eb3d974](https://github.com/telekom/scale/commit/eb3d974c9dc9b4f1ea89eeb356e513248617ce6d))


### Features

* **chip:** Add checkmark accessibility properties and update documentation ([#2472](https://github.com/telekom/scale/issues/2472)) ([a5ee592](https://github.com/telekom/scale/commit/a5ee592250744f34f2b4d61af8fce3de74dcc8ed))
* **combobox:** add required prop and update documentation ([#2466](https://github.com/telekom/scale/issues/2466)) ([2e7f881](https://github.com/telekom/scale/commit/2e7f88179695369e326e1394fc60f6df49aac67a))
* **text-area:** adds hide label visibility with screenreader support for datepicker and textarea ([#2480](https://github.com/telekom/scale/issues/2480)) ([76262a5](https://github.com/telekom/scale/commit/76262a5ff7434929fd5461b51a5c3f42b60da120))

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The 3.0.0-beta.160 changelog entry doesn’t mention the scale-tag keyboard/a11y change described by this PR (it only lists date-picker/style/chip/combobox/text-area items). If this PR is meant to deliver the tag navigation fix, it should be reflected in this release section; otherwise the PR description/title may need to be aligned with the actual release contents.

Copilot uses AI. Check for mistakes.
expect(secondTabindex).toBe('-1');
expect(thirdTabindex).toBe('-1');
});

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The new e2e coverage only validates arrow navigation when tags are direct siblings in a <div>. Since scale-tag is commonly rendered inside wrappers (e.g. <ul><li><scale-tag/></li>…</ul> in the data-grid tags cell), consider adding an e2e case for wrapped tags (and potentially disabled/href variants) to ensure the roving implementation works in real-world markup.

Suggested change
it('should navigate between wrapped tags on ArrowRight key', async () => {
const page = await newE2EPage();
await page.setContent(`
<ul>
<li><scale-tag id="tag1">Tag 1</scale-tag></li>
<li><scale-tag id="tag2" href="#">Tag 2</scale-tag></li>
<li><scale-tag id="tag3">Tag 3</scale-tag></li>
</ul>
`);
const firstTag = await page.find('#tag1');
const secondTag = await page.find('#tag2');
await firstTag.focus();
await page.keyboard.press('ArrowRight');
await page.waitForChanges();
const firstTabindex = await firstTag.getAttribute('tabindex');
const secondTabindex = await secondTag.getAttribute('tabindex');
expect(firstTabindex).toBe('-1');
expect(secondTabindex).toBe('0');
});

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +89
private getSiblingTags(): HTMLElement[] {
if (!this.hostElement.parentElement) return [];

const allTags = Array.from(
this.hostElement.parentElement.querySelectorAll('scale-tag')
) as HTMLElement[];
return allTags;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

getSiblingTags() uses parentElement.querySelectorAll('scale-tag'), which selects all descendant tags under the parent (not just siblings) and also fails to group tags when they are wrapped (e.g. <ul><li><scale-tag/></li>...</ul> as used by the data-grid TagsCell). This means roving tabindex/arrow navigation will be inconsistent depending on markup. Consider restricting to direct children (e.g. :scope > scale-tag / filtering parentElement.children) or introducing an explicit roving container that scale-tag queries via closest().

Suggested change
private getSiblingTags(): HTMLElement[] {
if (!this.hostElement.parentElement) return [];
const allTags = Array.from(
this.hostElement.parentElement.querySelectorAll('scale-tag')
) as HTMLElement[];
return allTags;
private getTagsFromContainer(container: HTMLElement): HTMLElement[] {
return Array.from(container.children).reduce((tags: HTMLElement[], child) => {
if (!(child instanceof HTMLElement)) return tags;
if (child.tagName.toLowerCase() === 'scale-tag') {
tags.push(child);
return tags;
}
const nestedTag = child.querySelector('scale-tag');
if (nestedTag instanceof HTMLElement) {
tags.push(nestedTag);
}
return tags;
}, []);
}
private getRovingContainer(): HTMLElement | null {
let current = this.hostElement.parentElement;
while (current) {
const tags = this.getTagsFromContainer(current);
if (tags.length > 1 && tags.includes(this.hostElement)) {
return current;
}
current = current.parentElement;
}
return this.hostElement.parentElement;
}
private getSiblingTags(): HTMLElement[] {
const container = this.getRovingContainer();
if (!container) return [];
return this.getTagsFromContainer(container);

Copilot uses AI. Check for mistakes.
Implement roving tabindex and ARIA role for scale-tag so arrow keys
move focus and screen readers announce tags. Add unit and e2e tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@amir-ba amir-ba force-pushed the copilot/issue-2419-scale-tag-keyboard-fix branch from 73792c8 to 6ae99e2 Compare April 28, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants