diff --git a/.changeset/calm-birds-skip.md b/.changeset/calm-birds-skip.md new file mode 100644 index 00000000000..9f950be996a --- /dev/null +++ b/.changeset/calm-birds-skip.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': minor +--- + +Add localized Main and conditional Footer skip links to `ix-application`, including configurable Main targeting and accessible focus management. diff --git a/packages/angular-standalone-test-app/src/preview-examples/application.html b/packages/angular-standalone-test-app/src/preview-examples/application.html index 03b1ebe7dae..6cc85b77116 100644 --- a/packages/angular-standalone-test-app/src/preview-examples/application.html +++ b/packages/angular-standalone-test-app/src/preview-examples/application.html @@ -7,7 +7,7 @@ LICENSE file in the root directory of this source tree. --> - + @@ -17,8 +17,9 @@ Item 2 - + + diff --git a/packages/angular-test-app/src/preview-examples/application.html b/packages/angular-test-app/src/preview-examples/application.html index 1bea2aeeca9..c126d86ee6e 100644 --- a/packages/angular-test-app/src/preview-examples/application.html +++ b/packages/angular-test-app/src/preview-examples/application.html @@ -7,7 +7,7 @@ LICENSE file in the root directory of this source tree. --> - + @@ -17,8 +17,9 @@ Item 2 - + + diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index c839e47ede8..0449703d98c 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -31,14 +31,14 @@ export declare interface IxActionCard extends Components.IxActionCard {} @ProxyCmp({ - inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'forceBreakpoint', 'theme'] + inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'disableSkipLinks', 'forceBreakpoint', 'i18nSkipToFooter', 'i18nSkipToMain', 'skipLinkMainTargetId', 'theme'] }) @Component({ selector: 'ix-application', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'forceBreakpoint', 'theme'], + inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'disableSkipLinks', 'forceBreakpoint', 'i18nSkipToFooter', 'i18nSkipToMain', 'skipLinkMainTargetId', 'theme'], standalone: false }) export class IxApplication { diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index b402e16959f..83233ef0162 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -142,14 +142,14 @@ export declare interface IxActionCard extends Components.IxActionCard {} @ProxyCmp({ defineCustomElementFn: defineIxApplication, - inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'forceBreakpoint', 'theme'] + inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'disableSkipLinks', 'forceBreakpoint', 'i18nSkipToFooter', 'i18nSkipToMain', 'skipLinkMainTargetId', 'theme'] }) @Component({ selector: 'ix-application', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'forceBreakpoint', 'theme'], + inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'disableSkipLinks', 'forceBreakpoint', 'i18nSkipToFooter', 'i18nSkipToMain', 'skipLinkMainTargetId', 'theme'], }) export class IxApplication { protected el: HTMLIxApplicationElement; diff --git a/packages/core/component-api.json b/packages/core/component-api.json index 1ca5d264fc8..c0b55a202ab 100644 --- a/packages/core/component-api.json +++ b/packages/core/component-api.json @@ -132,6 +132,17 @@ "required": false, "deprecated": false }, + { + "name": "disableSkipLinks", + "attribute": "disable-skip-links", + "type": "boolean", + "mutable": false, + "reflectToAttribute": false, + "default": "false", + "optional": false, + "required": false, + "deprecated": false + }, { "name": "forceBreakpoint", "attribute": "force-breakpoint", @@ -142,6 +153,38 @@ "required": false, "deprecated": false }, + { + "name": "i18nSkipToFooter", + "attribute": "i18n-skip-to-footer", + "type": "string", + "mutable": false, + "reflectToAttribute": false, + "default": "'Skip to footer'", + "optional": false, + "required": false, + "deprecated": false + }, + { + "name": "i18nSkipToMain", + "attribute": "i18n-skip-to-main", + "type": "string", + "mutable": false, + "reflectToAttribute": false, + "default": "'Skip to main content'", + "optional": false, + "required": false, + "deprecated": false + }, + { + "name": "skipLinkMainTargetId", + "attribute": "skip-link-main-target-id", + "type": "string | undefined", + "mutable": false, + "reflectToAttribute": false, + "optional": true, + "required": false, + "deprecated": false + }, { "name": "theme", "attribute": "theme", diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 91346c33a99..aeecc91ff7b 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -176,10 +176,33 @@ export namespace Components { * @default 'system' */ "colorSchema"?: ThemeVariant; + /** + * Disable the built-in links for bypassing repeated application content. Only disable them when an equivalent bypass mechanism is provided elsewhere. + * @since 6.0.0 + * @default false + */ + "disableSkipLinks": boolean; /** * Change the responsive layout of the menu structure */ "forceBreakpoint": Breakpoint | undefined; + /** + * Localized text for the link that focuses the application footer. + * @since 6.0.0 + * @default 'Skip to footer' + */ + "i18nSkipToFooter": string; + /** + * Localized text for the link that bypasses repeated application content and focuses the main region. + * @since 6.0.0 + * @default 'Skip to main content' + */ + "i18nSkipToMain": string; + /** + * ID of a light-DOM descendant to focus when the Main skip link is activated. Falls back to the internal main region when the target cannot be used. + * @since 6.0.0 + */ + "skipLinkMainTargetId"?: string; /** * Application theme */ @@ -7049,10 +7072,33 @@ declare namespace LocalJSX { * @default 'system' */ "colorSchema"?: ThemeVariant; + /** + * Disable the built-in links for bypassing repeated application content. Only disable them when an equivalent bypass mechanism is provided elsewhere. + * @since 6.0.0 + * @default false + */ + "disableSkipLinks"?: boolean; /** * Change the responsive layout of the menu structure */ "forceBreakpoint"?: Breakpoint | undefined; + /** + * Localized text for the link that focuses the application footer. + * @since 6.0.0 + * @default 'Skip to footer' + */ + "i18nSkipToFooter"?: string; + /** + * Localized text for the link that bypasses repeated application content and focuses the main region. + * @since 6.0.0 + * @default 'Skip to main content' + */ + "i18nSkipToMain"?: string; + /** + * ID of a light-DOM descendant to focus when the Main skip link is activated. Falls back to the internal main region when the target cannot be used. + * @since 6.0.0 + */ + "skipLinkMainTargetId"?: string; /** * Application theme */ @@ -12193,6 +12239,10 @@ declare namespace LocalJSX { "theme": string; "colorSchema": ThemeVariant; "forceBreakpoint": Breakpoint | undefined; + "disableSkipLinks": boolean; + "i18nSkipToMain": string; + "i18nSkipToFooter": string; + "skipLinkMainTargetId": string; } interface IxApplicationHeaderAttributes { "name": string; diff --git a/packages/core/src/components/application/application.scss b/packages/core/src/components/application/application.scss index 2f1d5ed2ed2..aa7ef087ca9 100644 --- a/packages/core/src/components/application/application.scss +++ b/packages/core/src/components/application/application.scss @@ -8,6 +8,7 @@ */ @use 'mixins/shadow-dom/component'; +@use 'mixins/fonts'; :host { display: flex; @@ -19,6 +20,43 @@ @include component.ix-component; + .skip-links { + margin: 0; + padding: 0; + list-style: none; + } + + .skip-link { + @include fonts.typography-body; + + position: absolute; + inset-block-start: -100%; + inset-inline-start: calc( + var(--ix-safe-area-inset-left, 0rem) + + var(--theme-focus--border-thickness) + + var(--theme-btn--focus--outline-offset) + ); + z-index: calc(var(--theme-z-index-fixed) + 1); + padding: var(--theme-space-1) var(--theme-space-3); + background-color: var(--theme-color-2); + color: var(--theme-color-primary); + white-space: nowrap; + + &:focus { + inset-block-start: calc( + var(--ix-safe-area-inset-top, 0rem) + + var(--theme-focus--border-thickness) + + var(--theme-btn--focus--outline-offset) + ); + } + + &:focus-visible { + outline: var(--theme-focus--border-thickness) solid + var(--theme-color-focus-bdr); + outline-offset: var(--theme-btn--focus--outline-offset); + } + } + ix-application-header { z-index: calc(var(--theme-z-index-sticky) + 1); } @@ -61,9 +99,21 @@ display: block; position: relative; width: 100%; + + &[hidden] { + display: none; + } } } +:host(:dir(rtl)) .skip-link { + inset-inline-start: calc( + var(--ix-safe-area-inset-right, 0rem) + + var(--theme-focus--border-thickness) + + var(--theme-btn--focus--outline-offset) + ); +} + :host(.breakpoint-md) { --ix-application-menu-margin-left: calc( 3.25rem + var(--ix-application-menu-safe-area-left, 0rem) diff --git a/packages/core/src/components/application/application.tsx b/packages/core/src/components/application/application.tsx index feb9e31ab9f..90ddfb2576e 100644 --- a/packages/core/src/components/application/application.tsx +++ b/packages/core/src/components/application/application.tsx @@ -16,10 +16,15 @@ import { applicationLayoutService } from '../utils/application-layout/service'; import { Breakpoint } from '../utils/breakpoints'; import { ContextProvider, useContextProvider } from '../utils/context'; import { menuController } from '../utils/menu-service/menu-service'; -import { hasSlottedElements } from '../utils/shadow-dom'; +import { hasSlottedContent, hasSlottedElements } from '../utils/shadow-dom'; import { themeSwitcher, ThemeVariant } from '../utils/theme-switcher'; import { Disposable } from '../utils/typed-event'; +const DEFAULT_SKIP_LINK_MAIN_LABEL = 'Skip to main content'; +const DEFAULT_SKIP_LINK_MAIN_TARGET_ID = 'ix-application-main-content'; +const DEFAULT_SKIP_LINK_FOOTER_LABEL = 'Skip to footer'; +const DEFAULT_SKIP_LINK_FOOTER_TARGET_ID = 'ix-application-footer'; + /** * @slot application-header - Header displayed at the top of the application. * @slot menu - Main application navigation. @@ -83,10 +88,48 @@ export class Application { */ @Prop() appSwitchConfig?: AppSwitchConfiguration; + /** + * Disable the built-in links for bypassing repeated application content. + * Only disable them when an equivalent bypass mechanism is provided + * elsewhere. + * + * @since 6.0.0 + */ + @Prop() disableSkipLinks = false; + + /** + * Localized text for the link that bypasses repeated application content and + * focuses the main region. + * + * @since 6.0.0 + */ + @Prop({ attribute: 'i18n-skip-to-main' }) i18nSkipToMain = + DEFAULT_SKIP_LINK_MAIN_LABEL; + + /** + * Localized text for the link that focuses the application footer. + * + * @since 6.0.0 + */ + @Prop({ attribute: 'i18n-skip-to-footer' }) i18nSkipToFooter = + DEFAULT_SKIP_LINK_FOOTER_LABEL; + + /** + * ID of a light-DOM descendant to focus when the Main skip link is activated. + * Falls back to the internal main region when the target cannot be used. + * + * @since 6.0.0 + */ + @Prop() skipLinkMainTargetId?: string; + @State() breakpoint: Breakpoint = 'lg'; @State() applicationSidebarSlotted = false; + @State() footerSlotted = false; private contextProvider?: ContextProvider; + private mainElement?: HTMLElement; + private footerElement?: HTMLElement; + private temporaryFocusTargetRestore?: () => void; get menu(): HTMLIxMenuElement | null { return this.hostElement.querySelector('ix-menu'); @@ -98,6 +141,12 @@ export class Application { ) as HTMLSlotElement; } + get footerSlot() { + return this.hostElement.shadowRoot!.querySelector( + 'slot[name="bottom"]' + ) as HTMLSlotElement; + } + private modeDisposable?: Disposable; private onContentClick() { @@ -107,6 +156,12 @@ export class Application { this.menu?.toggleMenu(false); } + private onContentKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.onContentClick(); + } + } + private setBreakpoints(breakpoints: Breakpoint[]) { if (this.forceBreakpoint) { applicationLayoutService.setBreakpoints([this.forceBreakpoint]); @@ -115,7 +170,190 @@ export class Application { } } + private get skipLinkMainLabel() { + return this.i18nSkipToMain?.trim() || DEFAULT_SKIP_LINK_MAIN_LABEL; + } + + private get skipLinkFooterLabel() { + return this.i18nSkipToFooter?.trim() || DEFAULT_SKIP_LINK_FOOTER_LABEL; + } + + private get skipLinkMainHref() { + const targetId = this.skipLinkMainTargetId?.trim(); + return `#${targetId || DEFAULT_SKIP_LINK_MAIN_TARGET_ID}`; + } + + private warn(message: string) { + console.warn(`ix-application: ${message}`); + } + + private validateSkipLinkLabel( + propertyName: 'i18nSkipToMain' | 'i18nSkipToFooter', + label: string | undefined, + fallback: string + ) { + if (!label?.trim()) { + this.warn( + `${propertyName} must not be empty. Using "${fallback}" instead.` + ); + } + } + + @Watch('i18nSkipToMain') + onI18nSkipToMainChange(label: string | undefined) { + this.validateSkipLinkLabel( + 'i18nSkipToMain', + label, + DEFAULT_SKIP_LINK_MAIN_LABEL + ); + } + + @Watch('i18nSkipToFooter') + onI18nSkipToFooterChange(label: string | undefined) { + this.validateSkipLinkLabel( + 'i18nSkipToFooter', + label, + DEFAULT_SKIP_LINK_FOOTER_LABEL + ); + } + + private isOwnedLightDomDescendant(element: HTMLElement) { + return element.closest('ix-application') === this.hostElement; + } + + private isUsableSkipLinkTarget(element: HTMLElement) { + if ( + element.matches(':disabled') || + element.closest('[hidden], [inert], [aria-hidden="true"]') + ) { + return false; + } + + const { display, visibility } = getComputedStyle(element); + return ( + display !== 'none' && + visibility !== 'hidden' && + element.getClientRects().length > 0 + ); + } + + private findCustomSkipLinkTarget(targetId: string) { + const matches = Array.from( + this.hostElement.querySelectorAll(`[id="${CSS.escape(targetId)}"]`) + ).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && + this.isOwnedLightDomDescendant(element) + ); + + if (matches.length !== 1 || !this.isUsableSkipLinkTarget(matches[0])) { + this.warn( + `skipLinkMainTargetId "${targetId}" must identify one usable descendant. Falling back to the main content.` + ); + return; + } + + return matches[0]; + } + + private restoreTemporaryTargetFocusability = () => { + this.temporaryFocusTargetRestore?.(); + }; + + private focusCustomSkipLinkTarget(target: HTMLElement) { + this.restoreTemporaryTargetFocusability(); + + if (target.tabIndex < 0 && !target.hasAttribute('tabindex')) { + target.setAttribute('tabindex', '-1'); + const restore = () => { + target.removeEventListener('blur', restore); + if (target.getAttribute('tabindex') === '-1') { + target.removeAttribute('tabindex'); + } + if (this.temporaryFocusTargetRestore === restore) { + this.temporaryFocusTargetRestore = undefined; + } + }; + this.temporaryFocusTargetRestore = restore; + target.addEventListener('blur', restore, { once: true }); + } + + try { + target.focus({ preventScroll: true }); + } catch { + this.restoreTemporaryTargetFocusability(); + return false; + } + + if (target.ownerDocument.activeElement !== target) { + this.restoreTemporaryTargetFocusability(); + return false; + } + + target.scrollIntoView({ + behavior: 'instant', + block: 'start', + inline: 'nearest', + }); + return true; + } + + private focusMainContent() { + this.mainElement?.focus({ preventScroll: true }); + this.mainElement?.scrollTo({ + behavior: 'instant', + top: 0, + left: 0, + }); + } + + private onSkipLinkMainClick(event: MouseEvent) { + event.preventDefault(); + + const targetId = this.skipLinkMainTargetId?.trim(); + if (!targetId) { + this.focusMainContent(); + return; + } + + const target = this.findCustomSkipLinkTarget(targetId); + if (!target) { + this.focusMainContent(); + return; + } + + if (!this.focusCustomSkipLinkTarget(target)) { + this.warn( + `skipLinkMainTargetId "${targetId}" does not identify a focusable descendant. Falling back to the main content.` + ); + this.focusMainContent(); + } + } + + private onSkipLinkFooterClick(event: MouseEvent) { + event.preventDefault(); + this.footerElement?.focus({ preventScroll: true }); + + if (this.hostElement.shadowRoot?.activeElement !== this.footerElement) { + this.warn('Could not focus the application footer.'); + } + } + + private updateFooterSlotted() { + this.footerSlotted = hasSlottedContent(this.footerSlot); + } + componentWillLoad() { + this.validateSkipLinkLabel( + 'i18nSkipToMain', + this.i18nSkipToMain, + DEFAULT_SKIP_LINK_MAIN_LABEL + ); + this.validateSkipLinkLabel( + 'i18nSkipToFooter', + this.i18nSkipToFooter, + DEFAULT_SKIP_LINK_FOOTER_LABEL + ); this.setBreakpoints(this.breakpoints); this.contextProvider = useContextProvider( @@ -137,8 +375,13 @@ export class Application { this.forceLayoutChange(this.forceBreakpoint); } + componentDidLoad() { + this.updateFooterSlotted(); + } + disconnectedCallback() { this.modeDisposable?.dispose(); + this.restoreTemporaryTargetFocusability(); } @Watch('theme') @@ -179,6 +422,30 @@ export class Application { [`breakpoint-${this.breakpoint}`]: true, }} > + {!this.disableSkipLinks && ( + + )}
@@ -199,11 +466,28 @@ export class Application { >
-
this.onContentClick()}> +
(this.mainElement = element)} + onClick={() => this.onContentClick()} + onKeyDown={(event) => this.onContentKeyDown(event)} + >
-
- +
(this.footerElement = element)} + > + this.updateFooterSlotted()} + >
diff --git a/packages/core/src/components/application/test/application.ct.ts b/packages/core/src/components/application/test/application.ct.ts index b01d71976af..fec533b4056 100644 --- a/packages/core/src/components/application/test/application.ct.ts +++ b/packages/core/src/components/application/test/application.ct.ts @@ -6,16 +6,578 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; import { regressionTest, viewPorts } from '@utils/test'; -regressionTest('accessibility', async ({ mount, makeAxeBuilder }) => { - await mount(``); +const captureWarnings = (page: Page) => { + const warnings: string[] = []; + page.on('console', (message) => { + if (message.type() === 'warning') { + warnings.push(message.text()); + } + }); + return warnings; +}; + +regressionTest('accessibility', async ({ mount, page, makeAxeBuilder }) => { + await mount(` + + + + Home + + Page content + + + `); + + const skipLink = page.getByRole('link', { name: 'Skip to main content' }); + await skipLink.focus(); + await expect(skipLink).toBeFocused(); + await expect(skipLink).toBeInViewport(); const results = await makeAxeBuilder().analyze(); expect(results.violations).toEqual([]); }); +regressionTest('renders', async ({ mount, page }) => { + await mount(`Page content`); + + const application = page.locator('ix-application'); + await expect(application).toHaveClass(/\bhydrated\b/); + await expect(application).toBeVisible(); +}); + +regressionTest( + 'renders a semantic skip-link list for available destinations', + async ({ mount, page }) => { + await mount(` + + Page content +
Footer content
+
+ `); + + const list = page.getByRole('list'); + await expect(list).toHaveCount(1); + await expect(list.getByRole('listitem')).toHaveCount(2); + await expect( + list.getByRole('link', { name: 'Skip to main content' }) + ).toHaveAttribute('href', '#ix-application-main-content'); + await expect( + list.getByRole('link', { name: 'Skip to footer' }) + ).toHaveAttribute('href', '#ix-application-footer'); + await expect(page.getByRole('contentinfo')).toBeVisible(); + } +); + +regressionTest( + 'places Main before Footer in the application focus order', + async ({ mount, page }) => { + await mount(` + + + + + + `); + + await expect(page.locator('ix-application')).toHaveClass(/\bhydrated\b/); + await expect( + page.getByRole('link', { name: 'Skip to footer' }) + ).toHaveCount(1); + await page.keyboard.press('Tab'); + + await expect( + page.getByRole('link', { name: 'Skip to main content' }) + ).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect( + page.getByRole('link', { name: 'Skip to footer' }) + ).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect( + page.getByRole('button', { name: 'Header action' }) + ).toBeFocused(); + } +); + +regressionTest( + 'adds and removes the Footer destination with bottom-slot content', + async ({ mount, page }) => { + await mount(`Page content`); + + const application = page.locator('ix-application'); + const footerLink = page.getByRole('link', { name: 'Skip to footer' }); + const footer = application.locator('footer'); + + await expect(footerLink).toHaveCount(0); + await expect(footer).toHaveAttribute('hidden', ''); + + await application.evaluate((element) => { + const footerContent = document.createElement('span'); + footerContent.slot = 'bottom'; + footerContent.textContent = 'Footer content'; + element.append(footerContent); + }); + + await expect(footerLink).toBeVisible(); + await expect(footer).not.toHaveAttribute('hidden', ''); + + await application.evaluate((element) => { + element.querySelector('[slot="bottom"]')?.remove(); + }); + + await expect(footerLink).toHaveCount(0); + await expect(footer).toHaveAttribute('hidden', ''); + } +); + +regressionTest( + 'closes an unpinned menu with Escape from the main content', + async ({ mount, page }) => { + await mount(` + + + Home + + Page content + + `); + + const menu = page.locator('ix-menu'); + await menu.evaluate((element: HTMLIxMenuElement) => + element.toggleMenu(true) + ); + await expect(menu).toHaveClass(/\bexpanded\b/); + + const main = page.locator('ix-application').locator('main'); + await main.press('Enter'); + await expect(menu).toHaveClass(/\bexpanded\b/); + + await main.press('Escape'); + await expect(menu).not.toHaveClass(/\bexpanded\b/); + } +); + +regressionTest( + 'shows the skip link at logical top-start only while focused', + async ({ mount, page }) => { + await page.setViewportSize({ width: 800, height: 600 }); + await mount(` + + Page content +
Footer content
+
+ `); + + const mainLink = page.getByRole('link', { name: 'Skip to main content' }); + const footerLink = page.getByRole('link', { name: 'Skip to footer' }); + await expect(mainLink).not.toBeInViewport(); + await expect(footerLink).not.toBeInViewport(); + + await mainLink.focus(); + + await expect(mainLink).toBeInViewport(); + await expect(footerLink).not.toBeInViewport(); + const outlineInset = await mainLink.evaluate((element) => { + const style = getComputedStyle(element); + return ( + Number.parseFloat(style.outlineWidth) + + Number.parseFloat(style.outlineOffset) + ); + }); + const ltrBox = await mainLink.boundingBox(); + expect(ltrBox?.x).toBe(outlineInset); + expect(ltrBox?.y).toBe(outlineInset); + + await page.keyboard.press('Tab'); + await expect(mainLink).not.toBeInViewport(); + await expect(footerLink).toBeInViewport(); + const footerBox = await footerLink.boundingBox(); + expect(footerBox?.x).toBe(ltrBox?.x); + expect(footerBox?.y).toBe(ltrBox?.y); + + await page.locator('ix-application').evaluate((element) => { + element.style.setProperty('--ix-safe-area-inset-top', '1rem'); + element.style.setProperty('--ix-safe-area-inset-left', '1rem'); + element.style.setProperty('--ix-safe-area-inset-right', '1rem'); + element.setAttribute('dir', 'rtl'); + }); + await footerLink.focus(); + + const rtlBox = await footerLink.boundingBox(); + expect(rtlBox?.y).toBe(16 + outlineInset); + expect(800 - (rtlBox?.x ?? 0) - (rtlBox?.width ?? 0)).toBe( + 16 + outlineInset + ); + } +); + +regressionTest( + 'focuses the Footer without changing Main scroll position', + async ({ mount, page }) => { + await page.setViewportSize({ width: 800, height: 400 }); + await mount(` + +
Page content
+ +
+ `); + + const application = page.locator('ix-application'); + const main = application.getByRole('main'); + const footer = application.getByRole('contentinfo'); + const footerLink = page.getByRole('link', { name: 'Skip to footer' }); + + await main.evaluate((element) => (element.scrollTop = 300)); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(300); + + await footerLink.focus(); + await footerLink.press('Enter'); + + await expect(footer).toBeFocused(); + expect(await main.evaluate((element) => element.scrollTop)).toBe(300); + + await page.keyboard.press('Tab'); + await expect( + page.getByRole('button', { name: 'Footer action' }) + ).toBeFocused(); + } +); + +regressionTest( + 'focuses and resets the internal main region by default', + async ({ mount, page }) => { + await page.setViewportSize({ width: 800, height: 400 }); + await mount(` + +
Page content
+
+ `); + + const main = page.locator('ix-application main'); + await main.evaluate((element) => (element.style.scrollBehavior = 'smooth')); + await main.evaluate((element) => (element.scrollTop = 300)); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(300); + + const link = page.getByRole('link', { name: 'Skip to main content' }); + await link.focus(); + await link.press('Enter'); + + await expect(main).toBeFocused(); + expect(await main.evaluate((element) => element.scrollTop)).toBe(0); + } +); + +regressionTest( + 'focuses and scrolls to a configured light-DOM target', + async ({ mount, page }) => { + await page.setViewportSize({ width: 800, height: 400 }); + await mount(` + +
+

Page title

+ +
+ `); + + const link = page.getByRole('link', { name: 'Skip to main content' }); + const target = page.locator('#page-title'); + const main = page.locator('ix-application main'); + + await link.focus(); + await link.press('Enter'); + + await expect(target).toBeFocused(); + await expect(target).toHaveAttribute('tabindex', '-1'); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + await page.locator('#next').focus(); + await expect(target).not.toHaveAttribute('tabindex'); + } +); + +regressionTest( + 'supports custom target IDs that require selector escaping', + async ({ mount, page }) => { + await mount(` + +

Escaped target

+
+ `); + + const link = page.getByRole('link', { name: 'Skip to main content' }); + await link.focus(); + await link.press('Enter'); + + await expect(page.getByText('Escaped target')).toBeFocused(); + } +); + +regressionTest( + 'restores temporary focusability when switching custom targets', + async ({ mount, page }) => { + await mount(` + +

First target

+

Second target

+ +
+ `); + + const application = page.locator('ix-application'); + const link = page.getByRole('link', { name: 'Skip to main content' }); + const firstTarget = page.locator('#first-target'); + const secondTarget = page.locator('#second-target'); + + await link.evaluate((element: HTMLAnchorElement) => element.click()); + await expect(firstTarget).toBeFocused(); + await expect(firstTarget).toHaveAttribute('tabindex', '-1'); + + await application.evaluate((element: HTMLIxApplicationElement) => { + element.skipLinkMainTargetId = 'second-target'; + }); + await expect(link).toHaveAttribute('href', '#second-target'); + await link.evaluate((element: HTMLAnchorElement) => element.click()); + + await expect(firstTarget).not.toHaveAttribute('tabindex'); + await expect(secondTarget).toBeFocused(); + await expect(secondTarget).toHaveAttribute('tabindex', '-1'); + + await page.locator('#next').focus(); + await expect(secondTarget).not.toHaveAttribute('tabindex'); + } +); + +regressionTest( + 'preserves existing target focusability', + async ({ mount, page }) => { + await mount(` + +
Target
+ +
+ `); + + const target = page.locator('#target'); + const link = page.getByRole('link', { name: 'Skip to main content' }); + await link.focus(); + await link.press('Enter'); + await expect(target).toBeFocused(); + + await page.locator('#next').focus(); + await expect(target).toHaveAttribute('tabindex', '0'); + } +); + +regressionTest( + 'does not add tabindex to a naturally focusable target', + async ({ mount, page }) => { + await mount(` + + + + `); + + const target = page.locator('#target'); + const link = page.getByRole('link', { name: 'Skip to main content' }); + await link.focus(); + await link.press('Enter'); + + await expect(target).toBeFocused(); + await expect(target).not.toHaveAttribute('tabindex'); + } +); + +const invalidTargetCases = [ + { + name: 'missing', + markup: ` + + Page content + + `, + }, + { + name: 'duplicate', + markup: ` + +
First
+
Second
+
+ `, + }, + { + name: 'out-of-scope', + markup: ` +
Outside target
+ + Page content + + `, + }, + { + name: 'hidden', + markup: ` + + + + `, + }, + { + name: 'inert', + markup: ` + +
+
Inert target
+
+
+ `, + }, + { + name: 'disabled', + markup: ` + + + + `, + }, +]; + +for (const { name, markup } of invalidTargetCases) { + regressionTest( + `warns and falls back for a ${name} custom target`, + async ({ mount, page }) => { + const warnings = captureWarnings(page); + await mount(markup); + + const link = page.getByRole('link', { name: 'Skip to main content' }); + await link.focus(); + await link.press('Enter'); + + await expect(page.locator('ix-application main')).toBeFocused(); + expect(warnings).toContainEqual( + expect.stringContaining('skipLinkMainTargetId "target"') + ); + } + ); +} + +regressionTest( + 'uses localized link text and target reference', + async ({ mount, page }) => { + await mount(` + +
Inhalt
+
Fußzeile
+
+ `); + + await expect( + page.getByRole('link', { name: 'Zum Inhalt springen' }) + ).toHaveAttribute('href', '#content'); + await expect( + page.getByRole('link', { name: 'Zur Fußzeile springen' }) + ).toHaveAttribute('href', '#ix-application-footer'); + } +); + +regressionTest( + 'warns and falls back when localized text is empty', + async ({ mount, page }) => { + const warnings = captureWarnings(page); + await mount(` + + Page content +
Footer content
+
+ `); + + await expect( + page.getByRole('link', { name: 'Skip to main content' }) + ).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Skip to footer' }) + ).toBeVisible(); + expect(warnings).toContainEqual( + expect.stringContaining('i18nSkipToMain must not be empty') + ); + expect(warnings).toContainEqual( + expect.stringContaining('i18nSkipToFooter must not be empty') + ); + } +); + +regressionTest( + 'can disable the built-in skip-link list', + async ({ mount, page }) => { + await mount(` + + + + + + `); + + await expect( + page.getByRole('link', { name: 'Skip to main content' }) + ).toHaveCount(0); + await expect( + page.getByRole('link', { name: 'Skip to footer' }) + ).toHaveCount(0); + + await expect(page.locator('ix-application')).toHaveClass(/\bhydrated\b/); + await page.keyboard.press('Tab'); + await expect( + page.getByRole('button', { name: 'Header action' }) + ).toBeFocused(); + } +); + +regressionTest( + 'does not update browser history or the URL fragment', + async ({ mount, page }) => { + await mount(` + + Page content +
Footer content
+
+ `); + + const initialUrl = page.url(); + const initialHistoryLength = await page.evaluate(() => history.length); + const links = [ + page.getByRole('link', { name: 'Skip to main content' }), + page.getByRole('link', { name: 'Skip to footer' }), + ]; + + for (const link of links) { + await link.focus(); + await link.press('Enter'); + expect(page.url()).toBe(initialUrl); + expect(await page.evaluate(() => history.length)).toBe( + initialHistoryLength + ); + } + } +); + regressionTest( 'uses forced breakpoint on initial render', async ({ mount, page }) => { diff --git a/packages/core/src/components/menu-category/test/menu-category.ct.ts b/packages/core/src/components/menu-category/test/menu-category.ct.ts index 0feaf0a5057..23071a97b9e 100644 --- a/packages/core/src/components/menu-category/test/menu-category.ct.ts +++ b/packages/core/src/components/menu-category/test/menu-category.ct.ts @@ -393,7 +393,8 @@ regressionTest( const categoryElement = page.locator('ix-menu-category'); await expect(categoryElement).toHaveClass(/hydrated/); - // Navigate to category + // Navigate through skip link and burger button to the category + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowDown'); @@ -520,7 +521,8 @@ regressionTest( const categoryElement = page.locator('ix-menu-category'); await expect(categoryElement).toHaveClass(/hydrated/); - // Navigate to category + // Navigate through skip link and burger button to the category + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowDown'); diff --git a/packages/core/src/components/menu/test/menu.ct.ts b/packages/core/src/components/menu/test/menu.ct.ts index e4b4aa775e7..3172bfdd285 100644 --- a/packages/core/src/components/menu/test/menu.ct.ts +++ b/packages/core/src/components/menu/test/menu.ct.ts @@ -438,7 +438,8 @@ regressionTest( // Wait for roving tabindex to be initialised (set by rAF in componentDidLoad) await expect(items.first()).toHaveAttribute('aria-setsize', '3'); - // Tab twice: skip burger button and reach menu navigation container + // Tab through skip link and burger button to reach menu navigation container + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowDown'); diff --git a/packages/html-test-app/src/preview-examples/application.html b/packages/html-test-app/src/preview-examples/application.html index 34bfbac9289..9f01a6877a3 100644 --- a/packages/html-test-app/src/preview-examples/application.html +++ b/packages/html-test-app/src/preview-examples/application.html @@ -15,7 +15,7 @@ Application example - + @@ -25,10 +25,11 @@ Item 2 - + + diff --git a/packages/vue/src/components/ix-application.ts b/packages/vue/src/components/ix-application.ts index 09ea1c1c4d1..63836f9893b 100644 --- a/packages/vue/src/components/ix-application.ts +++ b/packages/vue/src/components/ix-application.ts @@ -11,5 +11,9 @@ export const IxApplication: StencilVueComponent = /*@__PURE__ 'colorSchema', 'forceBreakpoint', 'breakpoints', - 'appSwitchConfig' + 'appSwitchConfig', + 'disableSkipLinks', + 'i18nSkipToMain', + 'i18nSkipToFooter', + 'skipLinkMainTargetId' ]); diff --git a/testing/framework-tests/__snapshots__/about-and-legal-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/about-and-legal-aria-snapshot.yaml index 299a55122c7..8e244839f05 100644 --- a/testing/framework-tests/__snapshots__/about-and-legal-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/about-and-legal-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - navigation: - button "About & legal information" [expanded] - tooltip "About & legal information" @@ -5,5 +9,4 @@ - heading "About & legal information" [level=2] - button "Close About" - complementary -- main -- contentinfo \ No newline at end of file +- main \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/application-advanced-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/application-advanced-aria-snapshot.yaml index 53e30260c88..356ddb37db0 100644 --- a/testing/framework-tests/__snapshots__/application-advanced-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/application-advanced-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - text: My Application - button: - img @@ -10,5 +14,4 @@ - button "About & legal information" - tooltip "About & legal information" - complementary -- main: Example home content -- contentinfo \ No newline at end of file +- main: Example home content \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/application-app-switch-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/application-app-switch-aria-snapshot.yaml index cb1d0958d79..4f8ae04d395 100644 --- a/testing/framework-tests/__snapshots__/application-app-switch-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/application-app-switch-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - button "Apps" - text: My Application - button "Select config": @@ -8,5 +12,4 @@ - button "Item 1" - button "Item 2" - complementary -- main: My Content Page -- contentinfo \ No newline at end of file +- main: My Content Page \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/application-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/application-aria-snapshot.yaml index 9f5e69f3d8d..978f7eac9bb 100644 --- a/testing/framework-tests/__snapshots__/application-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/application-aria-snapshot.yaml @@ -1,7 +1,15 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#main-content" + - listitem: + - link "Skip to footer": + - /url: "#ix-application-footer" - text: My Application - navigation: - button "Item 1" - button "Item 2" - complementary - main: My Content Page -- contentinfo \ No newline at end of file +- contentinfo: + - button "Footer action" \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/application-breakpoints-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/application-breakpoints-aria-snapshot.yaml index f5c5150c942..541de9f3aae 100644 --- a/testing/framework-tests/__snapshots__/application-breakpoints-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/application-breakpoints-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - text: My Application - button "Select config": - img @@ -12,5 +16,4 @@ - radiogroup: - radio "Small" - radio "Medium" [checked] - - radio "Large" -- contentinfo \ No newline at end of file + - radio "Large" \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/application-header-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/application-header-aria-snapshot.yaml index 30d35ddc213..70620b48832 100644 --- a/testing/framework-tests/__snapshots__/application-header-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/application-header-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - text: My Application - button "Checkboxes" - button "Checkboxes" diff --git a/testing/framework-tests/__snapshots__/menu-category-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/menu-category-aria-snapshot.yaml index bc01ab9b478..d2a5e06441c 100644 --- a/testing/framework-tests/__snapshots__/menu-category-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/menu-category-aria-snapshot.yaml @@ -1,7 +1,10 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - navigation: - button "Home" - button "Normal Tab" - button "Top level Category" - complementary -- main -- contentinfo \ No newline at end of file +- main \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/popover-news-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/popover-news-aria-snapshot.yaml index c4d939bc381..485ad161ee9 100644 --- a/testing/framework-tests/__snapshots__/popover-news-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/popover-news-aria-snapshot.yaml @@ -1,9 +1,12 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - navigation: - button "About & legal information" - tooltip "About & legal information" - complementary - main -- contentinfo - img - img - text: Test diff --git a/testing/framework-tests/__snapshots__/settings-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/settings-aria-snapshot.yaml index b9cd5632e13..ca6ff276544 100644 --- a/testing/framework-tests/__snapshots__/settings-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/settings-aria-snapshot.yaml @@ -1,3 +1,7 @@ +- list: + - listitem: + - link "Skip to main content": + - /url: "#ix-application-main-content" - navigation: - button "Settings" [expanded] - tooltip "Settings" @@ -6,5 +10,4 @@ - button "Close Settings" - text: Example Setting 1 Example Setting 2 - complementary -- main -- contentinfo \ No newline at end of file +- main \ No newline at end of file diff --git a/testing/framework-tests/tests/application-skip-link-axe.spec.ts b/testing/framework-tests/tests/application-skip-link-axe.spec.ts new file mode 100644 index 00000000000..a27c3ac5b9f --- /dev/null +++ b/testing/framework-tests/tests/application-skip-link-axe.spec.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect, test } from '@playwright/test'; +import { createAxeBuilder, waitForReadiness } from './utils'; + +test('application skip links - accessibility check', async ({ page }) => { + await page.goto('/preview/application'); + await waitForReadiness(page); + + const skipLink = page.getByRole('link', { name: 'Skip to main content' }); + await skipLink.focus(); + await expect(skipLink).toBeFocused(); + await expect(skipLink).toBeInViewport(); + await page.keyboard.press('Tab'); + + const footerSkipLink = page.getByRole('link', { name: 'Skip to footer' }); + await expect(footerSkipLink).toBeFocused(); + await expect(footerSkipLink).toBeInViewport(); + await expect(skipLink).not.toBeInViewport(); + + const results = await createAxeBuilder(page) + .include('ix-application') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + + expect(results.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/application-skip-link.spec.ts b/testing/framework-tests/tests/application-skip-link.spec.ts new file mode 100644 index 00000000000..019c3d54e1c --- /dev/null +++ b/testing/framework-tests/tests/application-skip-link.spec.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect, test } from '@playwright/test'; +import { waitForReadiness } from './utils'; + +test('application skip links use the expected targets', async ({ page }) => { + await page.goto('/preview/application'); + await waitForReadiness(page); + + const target = page.locator('#main-content'); + const skipLink = page.getByRole('link', { + name: 'Skip to main content', + }); + const footerSkipLink = page.getByRole('link', { + name: 'Skip to footer', + }); + + await expect(skipLink).toHaveAttribute('href', '#main-content'); + await expect(footerSkipLink).toHaveAttribute( + 'href', + '#ix-application-footer' + ); + await expect(page.getByRole('listitem')).toHaveCount(2); + + await skipLink.focus(); + await skipLink.press('Enter'); + await expect(target).toBeFocused(); + + await footerSkipLink.focus(); + await footerSkipLink.press('Enter'); + await expect( + page.locator('ix-application').getByRole('contentinfo') + ).toBeFocused(); +}); diff --git a/testing/framework-tests/tests/working-with-axe.spec.ts b/testing/framework-tests/tests/working-with-axe.spec.ts index edd92d36306..2a631e2ff54 100644 --- a/testing/framework-tests/tests/working-with-axe.spec.ts +++ b/testing/framework-tests/tests/working-with-axe.spec.ts @@ -26,6 +26,7 @@ import './generated/tabs-overflow-axe.spec'; import './generated/tabs-rounded-axe.spec'; import './generated/time-range-axe.spec'; import './generated/timepicker-min-max-time-axe.spec'; +import './application-skip-link-axe.spec'; // Button tests - basic import './generated/buttons-axe.spec'; diff --git a/testing/framework-tests/tests/working.spec.ts b/testing/framework-tests/tests/working.spec.ts index 14876835fcb..8e404cd46f2 100644 --- a/testing/framework-tests/tests/working.spec.ts +++ b/testing/framework-tests/tests/working.spec.ts @@ -26,6 +26,7 @@ import './generated/tabs-rounded.spec'; import './generated/tabs.spec'; import './generated/time-range.spec'; import './generated/timepicker-min-max-time.spec'; +import './application-skip-link.spec'; // Button tests - basic import './generated/buttons.spec'; diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-dark-linux.png index c3f513b9dfb..617b61e8ca8 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-light-linux.png index dcb4468b539..c71feab1cb6 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-basic-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-dark-linux.png index 35b565eb287..2a8e0186d01 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-light-linux.png index 8883be53426..d9bb4fbd924 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-expanded-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-dark-linux.png index 6b9fc315505..4277e40b022 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-light-linux.png index 8b58752f23a..378057d36ab 100644 Binary files a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/basic-navigation-large-basic-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-dark-linux.png new file mode 100644 index 00000000000..57d363e071d Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-light-linux.png new file mode 100644 index 00000000000..6a0cb23bbbc Binary files /dev/null and b/testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/tests/application/application.e2e.ts b/testing/visual-testing/tests/application/application.e2e.ts index fcf2140c1d7..9ad2db0cd0d 100644 --- a/testing/visual-testing/tests/application/application.e2e.ts +++ b/testing/visual-testing/tests/application/application.e2e.ts @@ -10,6 +10,22 @@ import { expect } from '@playwright/test'; import { regressionTest, viewPorts } from '@utils/test'; +regressionTest('focused skip link', async ({ page }) => { + await page.goto('application/basic'); + await page.setViewportSize(viewPorts.lg); + + const skipLink = page.getByRole('link', { + name: 'Skip to main content', + }); + await skipLink.focus(); + await expect(skipLink).toBeFocused(); + + await expect(page).toHaveScreenshot({ + fullPage: true, + animations: 'disabled', + }); +}); + regressionTest.describe('basic navigation large', () => { regressionTest('basic', async ({ page }) => { await page.goto('application/basic'); diff --git a/testing/visual-testing/tests/application/basic/index.html b/testing/visual-testing/tests/application/basic/index.html index 9fa1f89beef..f58bc22c3ee 100644 --- a/testing/visual-testing/tests/application/basic/index.html +++ b/testing/visual-testing/tests/application/basic/index.html @@ -67,6 +67,7 @@ Item 2
Example content
+