From 7a0b9e82cf264dc09f44e13795cc189a00c2d5ef Mon Sep 17 00:00:00 2001 From: "Dmytro H." Date: Thu, 23 Jul 2026 11:16:52 +0300 Subject: [PATCH 1/3] feat(core/application): Add skip links to ix-application component --- .changeset/calm-birds-skip.md | 5 + .../src/preview-examples/application.html | 4 +- .../src/preview-examples/application.html | 4 +- packages/angular/src/components.ts | 4 +- packages/angular/standalone/src/components.ts | 4 +- packages/core/src/components.d.ts | 37 ++ .../components/application/application.scss | 41 +++ .../components/application/application.tsx | 186 +++++++++- .../application/test/application.ct.ts | 342 +++++++++++++++++- .../src/preview-examples/application.html | 4 +- .../src/preview-examples/application.tsx | 4 +- .../react/src/components/components.server.ts | 5 +- .../src/preview-examples/application.vue | 4 +- packages/vue/src/components/ix-application.ts | 5 +- .../about-and-legal-aria-snapshot.yaml | 2 + .../application-advanced-aria-snapshot.yaml | 2 + .../application-app-switch-aria-snapshot.yaml | 2 + .../application-aria-snapshot.yaml | 2 + ...application-breakpoints-aria-snapshot.yaml | 2 + .../application-header-aria-snapshot.yaml | 2 + .../menu-category-aria-snapshot.yaml | 2 + .../popover-news-aria-snapshot.yaml | 2 + .../__snapshots__/settings-aria-snapshot.yaml | 2 + .../tests/application-skip-link-axe.spec.ts | 22 ++ .../tests/application-skip-link.spec.ts | 25 ++ .../tests/working-with-axe.spec.ts | 1 + testing/framework-tests/tests/working.spec.ts | 1 + ...p-link-1-chromium---classic-dark-linux.png | Bin 0 -> 14407 bytes ...-link-1-chromium---classic-light-linux.png | Bin 0 -> 14184 bytes .../tests/application/application.e2e.ts | 16 + 30 files changed, 713 insertions(+), 19 deletions(-) create mode 100644 .changeset/calm-birds-skip.md create mode 100644 testing/framework-tests/tests/application-skip-link-axe.spec.ts create mode 100644 testing/framework-tests/tests/application-skip-link.spec.ts create mode 100644 testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-dark-linux.png create mode 100644 testing/visual-testing/__screenshots__/tests/application/application.e2e.ts/focused-skip-link-1-chromium---classic-light-linux.png diff --git a/.changeset/calm-birds-skip.md b/.changeset/calm-birds-skip.md new file mode 100644 index 00000000000..2d15f3a22aa --- /dev/null +++ b/.changeset/calm-birds-skip.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': minor +--- + +Add a default-on skip link to `ix-application` for bypassing repeated application-shell content, including configurable destination targeting with focus and scroll management, localized link text, and an explicit opt-out for applications that provide their own bypass mechanism. 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..aa2416813d3 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,7 +17,7 @@ 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..fd4aad119ac 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,7 +17,7 @@ Item 2 - + diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 1cadcb1b060..beacca68c23 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', 'disableSkipLink', 'forceBreakpoint', 'i18nSkipToContent', 'skipLinkTargetId', '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', 'disableSkipLink', 'forceBreakpoint', 'i18nSkipToContent', 'skipLinkTargetId', 'theme'], standalone: false }) export class IxApplication { diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index 9d1ca8c30d4..d3d13b9e5b8 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -141,14 +141,14 @@ export declare interface IxActionCard extends Components.IxActionCard {} @ProxyCmp({ defineCustomElementFn: defineIxApplication, - inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'forceBreakpoint', 'theme'] + inputs: ['appSwitchConfig', 'breakpoints', 'colorSchema', 'disableSkipLink', 'forceBreakpoint', 'i18nSkipToContent', 'skipLinkTargetId', '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', 'disableSkipLink', 'forceBreakpoint', 'i18nSkipToContent', 'skipLinkTargetId', 'theme'], }) export class IxApplication { protected el: HTMLIxApplicationElement; diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 2a62dbd0e69..48e56254e0b 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -174,10 +174,27 @@ export namespace Components { * @default 'system' */ "colorSchema"?: ThemeVariant; + /** + * Disable the built-in link for bypassing repeated application content. Only disable it when an equivalent bypass mechanism is provided elsewhere. + * @since 5.2.0 + * @default false + */ + "disableSkipLink": boolean; /** * Change the responsive layout of the menu structure */ "forceBreakpoint": Breakpoint | undefined; + /** + * Localized text for the link that bypasses repeated application content. + * @since 5.2.0 + * @default 'Skip to main content' + */ + "i18nSkipToContent": string; + /** + * ID of a light-DOM descendant to focus when the skip link is activated. Falls back to the internal main region when the target cannot be used. + * @since 5.2.0 + */ + "skipLinkTargetId"?: string; /** * Application theme */ @@ -6931,10 +6948,27 @@ declare namespace LocalJSX { * @default 'system' */ "colorSchema"?: ThemeVariant; + /** + * Disable the built-in link for bypassing repeated application content. Only disable it when an equivalent bypass mechanism is provided elsewhere. + * @since 5.2.0 + * @default false + */ + "disableSkipLink"?: boolean; /** * Change the responsive layout of the menu structure */ "forceBreakpoint"?: Breakpoint | undefined; + /** + * Localized text for the link that bypasses repeated application content. + * @since 5.2.0 + * @default 'Skip to main content' + */ + "i18nSkipToContent"?: string; + /** + * ID of a light-DOM descendant to focus when the skip link is activated. Falls back to the internal main region when the target cannot be used. + * @since 5.2.0 + */ + "skipLinkTargetId"?: string; /** * Application theme */ @@ -11977,6 +12011,9 @@ declare namespace LocalJSX { "theme": string; "colorSchema": ThemeVariant; "forceBreakpoint": Breakpoint | undefined; + "disableSkipLink": boolean; + "i18nSkipToContent": string; + "skipLinkTargetId": 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..2c466dacd5a 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,38 @@ @include component.ix-component; + .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); } @@ -64,6 +97,14 @@ } } +: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..7fff1f58143 100644 --- a/packages/core/src/components/application/application.tsx +++ b/packages/core/src/components/application/application.tsx @@ -20,6 +20,9 @@ import { hasSlottedElements } from '../utils/shadow-dom'; import { themeSwitcher, ThemeVariant } from '../utils/theme-switcher'; import { Disposable } from '../utils/typed-event'; +const DEFAULT_SKIP_LINK_LABEL = 'Skip to main content'; +const DEFAULT_SKIP_LINK_TARGET_ID = 'ix-application-main-content'; + /** * @slot application-header - Header displayed at the top of the application. * @slot menu - Main application navigation. @@ -83,10 +86,36 @@ export class Application { */ @Prop() appSwitchConfig?: AppSwitchConfiguration; + /** + * Disable the built-in link for bypassing repeated application content. + * Only disable it when an equivalent bypass mechanism is provided elsewhere. + * + * @since 5.2.0 + */ + @Prop() disableSkipLink = false; + + /** + * Localized text for the link that bypasses repeated application content. + * + * @since 5.2.0 + */ + @Prop({ attribute: 'i18n-skip-to-content' }) i18nSkipToContent = + DEFAULT_SKIP_LINK_LABEL; + + /** + * ID of a light-DOM descendant to focus when the skip link is activated. + * Falls back to the internal main region when the target cannot be used. + * + * @since 5.2.0 + */ + @Prop() skipLinkTargetId?: string; + @State() breakpoint: Breakpoint = 'lg'; @State() applicationSidebarSlotted = false; private contextProvider?: ContextProvider; + private mainElement?: HTMLElement; + private temporaryFocusTarget?: HTMLElement; get menu(): HTMLIxMenuElement | null { return this.hostElement.querySelector('ix-menu'); @@ -115,7 +144,146 @@ export class Application { } } + private get skipLinkLabel() { + return this.i18nSkipToContent?.trim() || DEFAULT_SKIP_LINK_LABEL; + } + + private get skipLinkHref() { + const targetId = this.skipLinkTargetId?.trim(); + return `#${targetId || DEFAULT_SKIP_LINK_TARGET_ID}`; + } + + private warn(message: string) { + console.warn(`ix-application: ${message}`); + } + + private validateSkipLinkLabel(label: string | undefined) { + if (!label?.trim()) { + this.warn( + `i18nSkipToContent must not be empty. Using "${DEFAULT_SKIP_LINK_LABEL}" instead.` + ); + } + } + + @Watch('i18nSkipToContent') + onI18nSkipToContentChange(label: string | undefined) { + this.validateSkipLinkLabel(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]') + ).filter( + (element) => + element instanceof HTMLElement && + element.id === targetId && + this.isOwnedLightDomDescendant(element) + ); + + if (matches.length !== 1 || !this.isUsableSkipLinkTarget(matches[0])) { + this.warn( + `skipLinkTargetId "${targetId}" must identify one usable descendant. Falling back to the main content.` + ); + return; + } + + return matches[0]; + } + + private restoreTemporaryTargetFocusability = () => { + this.temporaryFocusTarget?.removeEventListener( + 'blur', + this.restoreTemporaryTargetFocusability + ); + if (this.temporaryFocusTarget?.getAttribute('tabindex') === '-1') { + this.temporaryFocusTarget.removeAttribute('tabindex'); + } + this.temporaryFocusTarget = undefined; + }; + + private focusCustomSkipLinkTarget(target: HTMLElement) { + if (target.tabIndex < 0 && !target.hasAttribute('tabindex')) { + target.setAttribute('tabindex', '-1'); + this.temporaryFocusTarget = target; + target.addEventListener('blur', this.restoreTemporaryTargetFocusability, { + 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 onSkipLinkClick(event: MouseEvent) { + event.preventDefault(); + + const targetId = this.skipLinkTargetId?.trim(); + if (!targetId) { + this.focusMainContent(); + return; + } + + const target = this.findCustomSkipLinkTarget(targetId); + if (!target) { + this.focusMainContent(); + return; + } + + if (!this.focusCustomSkipLinkTarget(target)) { + this.warn( + `skipLinkTargetId "${targetId}" does not identify a focusable descendant. Falling back to the main content.` + ); + this.focusMainContent(); + } + } + componentWillLoad() { + this.validateSkipLinkLabel(this.i18nSkipToContent); this.setBreakpoints(this.breakpoints); this.contextProvider = useContextProvider( @@ -139,6 +307,7 @@ export class Application { disconnectedCallback() { this.modeDisposable?.dispose(); + this.restoreTemporaryTargetFocusability(); } @Watch('theme') @@ -179,6 +348,15 @@ export class Application { [`breakpoint-${this.breakpoint}`]: true, }} > + {!this.disableSkipLink && ( + + )}
@@ -199,7 +377,13 @@ export class Application { >
-
this.onContentClick()}> +
(this.mainElement = element)} + onClick={() => this.onContentClick()} + >