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..0b4e49e4432 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,37 @@ @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 +96,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..d436ac34fd9 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 temporaryFocusTargetRestore?: () => void; get menu(): HTMLIxMenuElement | null { return this.hostElement.querySelector('ix-menu'); @@ -107,6 +136,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 +150,147 @@ 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="${CSS.escape(targetId)}"]`) + ).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && + 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.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 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 +314,7 @@ export class Application { disconnectedCallback() { this.modeDisposable?.dispose(); + this.restoreTemporaryTargetFocusability(); } @Watch('theme') @@ -179,6 +355,15 @@ export class Application { [`breakpoint-${this.breakpoint}`]: true, }} > + {!this.disableSkipLink && ( + + )}
@@ -199,7 +384,14 @@ export class Application { >
-
this.onContentClick()}> +
(this.mainElement = element)} + onClick={() => this.onContentClick()} + onKeyDown={(event) => this.onContentKeyDown(event)} + >