From 45ceff206888535bec81948ba8c39704551ec62a Mon Sep 17 00:00:00 2001 From: Stoyan Date: Fri, 15 May 2026 09:13:39 +0300 Subject: [PATCH] feat(ui5-button): [PoC] link behaviour in the button --- packages/main/src/Button.ts | 110 ++++++++++++++-- packages/main/src/ButtonTemplate.tsx | 161 ++++++++++++++--------- packages/main/src/themes/Button.css | 9 ++ packages/main/test/pages/ButtonHref.html | 107 +++++++++++++++ 4 files changed, 319 insertions(+), 68 deletions(-) create mode 100644 packages/main/test/pages/ButtonHref.html diff --git a/packages/main/src/Button.ts b/packages/main/src/Button.ts index e880b19bc5ad..5ff9d5fa767e 100644 --- a/packages/main/src/Button.ts +++ b/packages/main/src/Button.ts @@ -28,6 +28,7 @@ import { isDesktop, isSafari, } from "@ui5/webcomponents-base/dist/Device.js"; +import { getLocationHostname, getLocationPort, getLocationProtocol } from "@ui5/webcomponents-base/dist/Location.js"; import willShowContent from "@ui5/webcomponents-base/dist/util/willShowContent.js"; import { submitForm, resetForm } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js"; import { getEnableDefaultTooltips } from "@ui5/webcomponents-base/dist/config/Tooltips.js"; @@ -296,6 +297,10 @@ class Button extends UI5Element implements IButton { * * **Note:** Use ButtonAccessibleRole.Link role only with a press handler, which performs a navigation. In all other scenarios the default button semantics are recommended. * + * **Note:** When the `href` property is set, the button renders as a native anchor element + * with implicit link semantics. In that case, this property is ignored. + * Consider using `href` instead of `accessibleRole="Link"` for navigation scenarios. + * * @default "Button" * @public * @since 1.23 @@ -303,6 +308,38 @@ class Button extends UI5Element implements IButton { @property() accessibleRole: `${ButtonAccessibleRole}` = "Button"; + /** + * Defines the URL the button navigates to when activated. + * When set, the component renders as an HTML `` element internally, + * providing proper navigation semantics (link role, URL preview on hover, + * right-click context menu, middle-click to open in new tab). + * + * **Note:** When `href` is set, the `type` property (Submit/Reset) is ignored + * and the button does not participate in form submission. + * @default undefined + * @public + * @since 2.x.0 + */ + @property() + href?: string; + + /** + * Defines where to display the linked URL. + * + * Available options: + * - `_self` (default browser behavior) + * - `_top` + * - `_blank` + * - `_parent` + * + * **Note:** This property is only used when `href` is set. + * @default undefined + * @public + * @since 2.x.0 + */ + @property() + target?: string; + /** * Used to switch the active state (pressed or not) of the component. * @private @@ -392,6 +429,9 @@ class Button extends UI5Element implements IButton { @property({ type: Boolean, noAttribute: true }) _isSpacePressed = false; + @property({ noAttribute: true }) + _rel: string | undefined; + /** * Constantly updated value of texts collected from the accessibleNameRef elements * @private @@ -419,11 +459,22 @@ class Button extends UI5Element implements IButton { _deactivate: () => void; _onclickBound: (e: MouseEvent) => void; _clickHandlerAttached = false; + /** + * A hidden link element (never rendered) used purely for URL parsing. + * When the button links to another website, we need to protect the user by adding + * rel="noreferrer noopener" — this prevents the destination page from being able to + * tamper with or spy on the page the user came from. + * The browser's built-in URL parser (via anchor.hostname etc.) tells us whether the + * link goes to another website or stays on the same one. + * @private + */ + _dummyAnchor: HTMLAnchorElement; @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; constructor() { super(); + this._dummyAnchor = document.createElement("a"); this._deactivate = () => { if (activeButton) { activeButton._setActiveState(false); @@ -497,6 +548,13 @@ class Button extends UI5Element implements IButton { const defaultTooltip = await this.getDefaultTooltip(); this.buttonTitle = this.iconOnly ? this.tooltip ?? defaultTooltip : this.tooltip; + + if (this._isLink) { + const needsNoReferrer = this.target === "_blank" && this._isCrossOrigin(this.href!); + this._rel = needsNoReferrer ? "noreferrer noopener" : undefined; + } else { + this._rel = undefined; + } } _setBadgeOverlayStyle() { @@ -516,6 +574,11 @@ class Button extends UI5Element implements IButton { return; } + if (this._isLink && this.disabled) { + e.preventDefault(); + return; + } + if (this.loading) { e.preventDefault(); return; @@ -541,12 +604,14 @@ class Button extends UI5Element implements IButton { return; } - if (this._isSubmit) { - submitForm(this); - } + if (!this._isLink) { + if (this._isSubmit) { + submitForm(this); + } - if (this._isReset) { - resetForm(this); + if (this._isReset) { + resetForm(this); + } } if (isSafari()) { @@ -582,10 +647,15 @@ class Button extends UI5Element implements IButton { if (isShift(e) || isEscape(e)) { this._cancelAction = true; } else if (isSpace(e)) { + if (this._isLink) { + return; + } this._isSpacePressed = true; } - if ((isSpace(e) || isEnter(e))) { + if (isEnter(e)) { + this._setActiveState(true); + } else if (isSpace(e) && !this._isLink) { this._setActiveState(true); } else if (this._cancelAction) { this._setActiveState(false); @@ -593,6 +663,10 @@ class Button extends UI5Element implements IButton { } _onkeyup(e: KeyboardEvent) { + if (this._isLink && isSpace(e)) { + return; + } + const isSpaceKey = isSpace(e); const isCancelKey = isShift(e) || isEscape(e); @@ -639,6 +713,22 @@ class Button extends UI5Element implements IButton { this.active = active; } + get parsedRef(): string | undefined { + return (this.href && this.href.length > 0) ? this.href : undefined; + } + + get _isLink(): boolean { + return !!this.parsedRef; + } + + _isCrossOrigin(href: string): boolean { + this._dummyAnchor.href = href; + + return !(this._dummyAnchor.hostname === getLocationHostname() + && this._dummyAnchor.port === getLocationPort() + && this._dummyAnchor.protocol === getLocationProtocol()); + } + get hasButtonType() { return this.design !== ButtonDesign.Default && this.design !== ButtonDesign.Transparent; } @@ -668,13 +758,17 @@ class Button extends UI5Element implements IButton { return Button.i18nBundle.getText(Button.typeTextMappings()[this.design]); } - get effectiveAccRole(): AriaRole { + get effectiveAccRole(): AriaRole | undefined { + if (this._isLink) { + return undefined; + } + return toLowercaseEnumValue(this.accessibleRole); } get tabIndexValue() { if (this.disabled) { - return; + return this._isLink ? -1 : undefined; } const tabindex = this.getAttribute("tabindex"); diff --git a/packages/main/src/ButtonTemplate.tsx b/packages/main/src/ButtonTemplate.tsx index dc6d1afa8c29..f5be596886d9 100644 --- a/packages/main/src/ButtonTemplate.tsx +++ b/packages/main/src/ButtonTemplate.tsx @@ -10,69 +10,110 @@ export default function ButtonTemplate(this: Button, injectedProps?: { ariaValueNow?: number, ariaValueText?: string, }) { - return (<> - + return (<> + {this._isLink ? ( + + {content} + + ) : ( + + )} {this.loading && + + + + + + Button with href (Link Mode) + + + + + + + + + + + Button with href — All Designs +

Each button below renders as a native <a> element. Hover to see URL in status bar, middle-click to open in new tab, right-click for link context menu.

+ +
+

All Design Variants

+ Default + Emphasized + Positive + Negative + Attention + Transparent +
+ +
+

With target="_blank" (opens new tab, auto-computes rel="noreferrer noopener" for cross-origin)

+ External Link (new tab) + Emphasized (new tab) + Share (new tab) +
+ +
+

Disabled State (aria-disabled, no navigation)

+ Default Disabled + Emphasized Disabled + Positive Disabled + Negative Disabled + Attention Disabled + Transparent Disabled +
+ +
+

Icon-Only with href (tooltip required for accessibility)

+ + + + +
+ +
+

With End Icon

+ Navigate Forward + Open Details + Download Options +
+ +
+

Text-Only (no icon)

+ Learn More + Get Started + View Documentation +
+ +
+

Comparison: Button without href (renders native <button>)

+ Regular Button + Regular Emphasized + Regular Transparent +
+ +
+

Click Event Test

+ Click me (check console) + +
+ + + + +