fix(tag): keyboard/a11y for tag arrow navigation (#2419)#2490
Conversation
There was a problem hiding this comment.
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.
| 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'); |
There was a problem hiding this comment.
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.
| 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'); | |
| } |
| 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(); |
There was a problem hiding this comment.
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.
| 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(); |
| role="option" | ||
| aria-selected="false" |
There was a problem hiding this comment.
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.
| role="option" | |
| aria-selected="false" |
| <Host | ||
| role="option" | ||
| aria-selected="false" | ||
| tabindex={0} |
There was a problem hiding this comment.
<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.
| tabindex={0} |
| # [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)) |
There was a problem hiding this comment.
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.
| expect(secondTabindex).toBe('-1'); | ||
| expect(thirdTabindex).toBe('-1'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| 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'); | |
| }); |
| private getSiblingTags(): HTMLElement[] { | ||
| if (!this.hostElement.parentElement) return []; | ||
|
|
||
| const allTags = Array.from( | ||
| this.hostElement.parentElement.querySelectorAll('scale-tag') | ||
| ) as HTMLElement[]; | ||
| return allTags; |
There was a problem hiding this comment.
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().
| 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); |
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>
73792c8 to
6ae99e2
Compare
Implement roving tabindex and ARIA role for scale-tag so arrow keys move focus and screen readers announce tags. Add unit and e2e tests.