Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 102 additions & 8 deletions packages/main/src/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -296,13 +297,49 @@ class Button extends UI5Element implements IButton {
*
* **Note:** Use <code>ButtonAccessibleRole.Link</code> 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
*/
@property()
accessibleRole: `${ButtonAccessibleRole}` = "Button";

/**
* Defines the URL the button navigates to when activated.
* When set, the component renders as an HTML `<a>` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
Expand All @@ -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()) {
Expand Down Expand Up @@ -582,17 +647,26 @@ 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);
}
}

_onkeyup(e: KeyboardEvent) {
if (this._isLink && isSpace(e)) {
return;
}

const isSpaceKey = isSpace(e);
const isCancelKey = isShift(e) || isEscape(e);

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
Expand Down
161 changes: 101 additions & 60 deletions packages/main/src/ButtonTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,69 +10,110 @@ export default function ButtonTemplate(this: Button, injectedProps?: {
ariaValueNow?: number,
ariaValueText?: string,
}) {
return (<>
<button
type="button"
class={{
"ui5-button-root": true,
"ui5-button-badge-placement-end": this.badge[0]?.design === "InlineText",
"ui5-button-badge-placement-end-top": this.badge[0]?.design === "OverlayText",
"ui5-button-badge-dot": this.badge[0]?.design === "AttentionDot"
}}
disabled={this.disabled}
data-sap-focus-ref
aria-pressed={injectedProps?.ariaPressed}
aria-valuemin={injectedProps?.ariaValueMin}
aria-valuemax={injectedProps?.ariaValueMax}
aria-valuenow={injectedProps?.ariaValueNow}
aria-valuetext={injectedProps?.ariaValueText}
onFocusOut={this._onfocusout}
onClick={this._onclick}
onMouseDown={this._onmousedown}
onKeyDown={this._onkeydown}
onKeyUp={this._onkeyup}
onTouchStart={this._ontouchstart}
onTouchEnd={this._ontouchend}
tabindex={this.tabIndexValue}
aria-expanded={this._computedAccessibilityAttributes?.expanded}
aria-controls={this._computedAccessibilityAttributes?.controls}
aria-haspopup={this._computedAccessibilityAttributes?.hasPopup}
aria-label={this._computedAccessibilityAttributes?.ariaLabel}
aria-keyshortcuts={this._computedAccessibilityAttributes?.ariaKeyShortcuts}
aria-description={this.ariaDescriptionText}
aria-busy={this.loading ? "true" : undefined}
title={this.buttonTitle}
part="button"
role={this.effectiveAccRole}
>
{ this.icon &&
<Icon
class="ui5-button-icon"
name={this.icon}
mode="Decorative"
part="icon"
/>
}
const content = (<>
{ this.icon &&
<Icon
class="ui5-button-icon"
name={this.icon}
mode="Decorative"
part="icon"
/>
}

<span id={`${this._id}-content`} class="ui5-button-text">
<bdi>
<slot></slot>
</bdi>
</span>

{this.endIcon &&
<Icon
class="ui5-button-end-icon"
name={this.endIcon}
mode="Decorative"
part="endIcon"
/>
}

<span id={`${this._id}-content`} class="ui5-button-text">
<bdi>
<slot></slot>
</bdi>
</span>
{this.shouldRenderBadge &&
<slot name="badge"/>
}
</>);

{this.endIcon &&
<Icon
class="ui5-button-end-icon"
name={this.endIcon}
mode="Decorative"
part="endIcon"
/>
}
const classes = {
"ui5-button-root": true,
"ui5-button-badge-placement-end": this.badge[0]?.design === "InlineText",
"ui5-button-badge-placement-end-top": this.badge[0]?.design === "OverlayText",
"ui5-button-badge-dot": this.badge[0]?.design === "AttentionDot",
};

{this.shouldRenderBadge &&
<slot name="badge"/>
}
</button>
return (<>
{this._isLink ? (
<a
class={classes}
href={this.parsedRef}
target={this.target}
rel={this._rel}
data-sap-focus-ref
aria-disabled={this.disabled ? "true" : undefined}
aria-pressed={injectedProps?.ariaPressed}
aria-valuemin={injectedProps?.ariaValueMin}
aria-valuemax={injectedProps?.ariaValueMax}
aria-valuenow={injectedProps?.ariaValueNow}
aria-valuetext={injectedProps?.ariaValueText}
onFocusOut={this._onfocusout}
onClick={this._onclick}
onMouseDown={this._onmousedown}
onKeyDown={this._onkeydown}
onKeyUp={this._onkeyup}
onTouchStart={this._ontouchstart}
onTouchEnd={this._ontouchend}
tabindex={this.tabIndexValue}
aria-expanded={this._computedAccessibilityAttributes?.expanded}
aria-controls={this._computedAccessibilityAttributes?.controls}
aria-haspopup={this._computedAccessibilityAttributes?.hasPopup}
aria-label={this._computedAccessibilityAttributes?.ariaLabel}
aria-keyshortcuts={this._computedAccessibilityAttributes?.ariaKeyShortcuts}
aria-description={this.ariaDescriptionText}
aria-busy={this.loading ? "true" : undefined}
title={this.buttonTitle}
part="button"
>
{content}
</a>
) : (
<button
type="button"
class={classes}
disabled={this.disabled}
data-sap-focus-ref
aria-pressed={injectedProps?.ariaPressed}
aria-valuemin={injectedProps?.ariaValueMin}
aria-valuemax={injectedProps?.ariaValueMax}
aria-valuenow={injectedProps?.ariaValueNow}
aria-valuetext={injectedProps?.ariaValueText}
onFocusOut={this._onfocusout}
onClick={this._onclick}
onMouseDown={this._onmousedown}
onKeyDown={this._onkeydown}
onKeyUp={this._onkeyup}
onTouchStart={this._ontouchstart}
onTouchEnd={this._ontouchend}
tabindex={this.tabIndexValue}
aria-expanded={this._computedAccessibilityAttributes?.expanded}
aria-controls={this._computedAccessibilityAttributes?.controls}
aria-haspopup={this._computedAccessibilityAttributes?.hasPopup}
aria-label={this._computedAccessibilityAttributes?.ariaLabel}
aria-keyshortcuts={this._computedAccessibilityAttributes?.ariaKeyShortcuts}
aria-description={this.ariaDescriptionText}
aria-busy={this.loading ? "true" : undefined}
title={this.buttonTitle}
part="button"
role={this.effectiveAccRole}
>
{content}
</button>
)}
{this.loading &&
<BusyIndicator
id={`${this._id}-button-busy-indicator`}
Expand Down
9 changes: 9 additions & 0 deletions packages/main/src/themes/Button.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@
user-select: none;
}

a.ui5-button-root {
text-decoration: none;
}

:host([disabled]) a.ui5-button-root {
pointer-events: none;
cursor: default;
}

:host(:not([active]):not([non-interactive]):not([_is-touch]):not([disabled]):hover),
:host(:not([hidden]):not([disabled]).ui5_hovered) {
background: var(--sapButton_Hover_Background);
Expand Down
Loading
Loading