From f504695ed0a03cc7bfaf7d3cbade02dc53bc38db Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 01:24:36 +0330 Subject: [PATCH 1/5] apply BitLink improvements #13101 --- .../Components/Utilities/Link/BitLink.razor | 25 +- .../Utilities/Link/BitLink.razor.cs | 105 +++++- .../Components/Utilities/Link/BitLink.scss | 8 +- .../Utilities/Link/BitLinkDemo.razor | 109 +++++- .../Utilities/Link/BitLinkDemo.razor.cs | 53 ++- .../Link/BitLinkDemo.razor.samples.cs | 69 ++-- .../Utilities/Link/BitLinkDemo.razor.scss | 7 + .../Link/BitLinkPropagationTest.razor | 14 + .../Components/Utilities/Link/BitLinkTests.cs | 328 +++++++++++++++++- 9 files changed, 635 insertions(+), 83 deletions(-) create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Link/BitLinkPropagationTest.razor diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor index afd69d8a74d..fab3c236ed3 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor @@ -5,41 +5,50 @@ { if (Href!.StartsWith('#')) { - + dir="@Dir?.ToString().ToLower()" + href="@(IsEnabled ? Href : null)" + role="@(IsEnabled ? null : "link")" + aria-disabled="@(IsEnabled ? null : "true")"> @ChildContent } else { - + role="@(IsEnabled ? null : "link")" + download="@(IsEnabled ? Download : null)" + target="@(IsEnabled && Target.HasValue() ? Target : null)" + aria-disabled="@(IsEnabled ? null : "true")"> @ChildContent } } else { - } \ No newline at end of file diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor.cs index 860978c7efe..91eeffde75f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor.cs @@ -6,6 +6,7 @@ public partial class BitLink : BitComponentBase { private string? _rel; + private string? _tabIndex; @@ -13,6 +14,14 @@ public partial class BitLink : BitComponentBase + /// + /// Keeps the disabled link focusable and discoverable by assistive technologies. + /// When enabled, the disabled state is conveyed using the aria-disabled attribute instead of removing + /// the element from the tab order, so keyboard and screen reader users can still find the link while its + /// navigation and click action stay suppressed. + /// + [Parameter] public bool AllowDisabledFocus { get; set; } + /// /// The content of the link, can be any custom tag or a text. /// @@ -25,14 +34,22 @@ public partial class BitLink : BitComponentBase public BitColor? Color { get; set; } /// - /// URL the link points to. + /// The value of the download attribute of the link when the Href parameter is provided. + /// Instructs the browser to download the linked resource instead of navigating to it, using the provided value + /// (if any) as the suggested file name (only works for same-origin, blob: and data: URLs). + /// + [Parameter] public string? Download { get; set; } + + /// + /// URL the link points to. If provided, the component renders an anchor tag, otherwise a button. + /// A value starting with the # character makes the link smooth-scroll the element with that id into view. /// [Parameter] - [CallOnSet(nameof(OnSetHrefAndRel))] + [CallOnSet(nameof(OnSetHrefRelAndTarget))] public string? Href { get; set; } /// - /// Removes the applying any foreground color to the link content. + /// Removes applying any foreground color to the link content, letting it keep its own color. /// [Parameter, ResetClassBuilder] public bool NoColor { get; set; } @@ -44,21 +61,36 @@ public partial class BitLink : BitComponentBase public bool NoUnderline { get; set; } /// - /// Callback for when the link clicked. + /// Callback for when the link is clicked. + /// It is invoked in every render mode of the link: on anchor links it runs alongside the navigation, + /// and on button links (no Href) it is the sole click action. /// [Parameter] public EventCallback OnClick { get; set; } /// /// If Href provided, specifies the relationship between the current document and the linked document. + /// Ignored for empty or hash-only (#) hrefs. + ///
+ /// When is set to _blank and no opener-related rel is provided, noopener is added automatically. ///
[Parameter] - [CallOnSet(nameof(OnSetHrefAndRel))] + [CallOnSet(nameof(OnSetHrefRelAndTarget))] public BitLinkRels? Rel { get; set; } /// - /// If Href provided, specifies how to open the link. + /// If true, stops the propagation of the click event to the parent elements. + /// Useful when the link is placed inside clickable containers like rows or cards. + /// + [Parameter] public bool StopPropagation { get; set; } + + /// + /// If Href provided, specifies how to open the link (e.g. _blank to open it in a new tab). + ///
+ /// When set to _blank and no opener-related is provided, noopener is added to the rel attribute automatically. ///
- [Parameter] public string? Target { get; set; } + [Parameter] + [CallOnSet(nameof(OnSetHrefRelAndTarget))] + public string? Target { get; set; } /// /// Styles the link with a fixed underline at all states. @@ -101,28 +133,71 @@ protected override void RegisterCssClasses() }); } - protected virtual async Task HandleClick(MouseEventArgs e) + /// + /// Gives focus to the root element of the link. + /// + /// + /// A disabled link is only focusable when keeps it in the tab order; + /// otherwise the browser ignores the call. + /// + /// + /// A ValueTask that represents the asynchronous focus operation. + /// + public ValueTask FocusAsync() => RootElement.FocusAsync(); + + /// + /// Gives focus to the root element of the link, optionally without scrolling it into view. + /// + /// + /// True to leave the page scrolled where it is instead of bringing the link into view. + /// + /// + /// A ValueTask that represents the asynchronous focus operation. + /// + public ValueTask FocusAsync(bool preventScroll) => RootElement.FocusAsync(preventScroll); + + + + protected override void OnParametersSet() { - if (IsEnabled is false) return; + _tabIndex = IsEnabled + ? TabIndex + : AllowDisabledFocus + ? (TabIndex ?? (Href.HasValue() ? "0" : null)) + : "-1"; - await OnClick.InvokeAsync(e); + base.OnParametersSet(); } - private async Task ScrollIntoView() + + + protected virtual async Task HandleClick(MouseEventArgs e) { if (IsEnabled is false) return; - await _js.BitUtilsScrollElementIntoView(Href![1..]); + await OnClick.InvokeAsync(e); + + if (Href.HasValue() && Href!.StartsWith('#')) + { + await _js.BitUtilsScrollElementIntoView(Href![1..]); + } } - private void OnSetHrefAndRel() + private void OnSetHrefRelAndTarget() { - if (Rel.HasValue is false || Href.HasNoValue() || Href!.StartsWith('#')) + if (Href.HasNoValue() || Href!.StartsWith('#')) { _rel = null; return; } - _rel = BitLinkRelUtils.GetRels(Rel.Value); + var rel = Rel.HasValue ? BitLinkRelUtils.GetRels(Rel.Value) : null; + + if (Target is "_blank" && (rel is null || (rel.Contains("noopener") is false && rel.Contains("noreferrer") is false))) + { + rel = rel.HasValue() ? $"{rel} noopener" : "noopener"; + } + + _rel = rel; } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.scss index bee952b6949..f0682551f1c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.scss @@ -39,10 +39,10 @@ } &:focus-visible { - // Cancel both layers of the focus-ring mixin applied above: the box-shadow ring and - // the forced-colors Highlight outline, so a disabled link never shows a focus indicator. - outline: none; - box-shadow: none; + // AllowDisabledFocus keeps a disabled link in the tab order, so the indicator has to stay + // visible - but drawn in the disabled color, since the vivid role ring of the enabled state + // contradicts the inert look and reads as actionable. + @include focus-ring(var(--bit-lnk-clr-dis)); } &:hover, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor index bfb95103d39..bd0b4721b77 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor @@ -2,31 +2,53 @@ + Description="Links lead to another part of an app, other pages, or help articles. They can also be used to initiate commands, rendering an anchor or a button while keeping the link look." /> +
+ A link only needs an Href and its text. A disabled link keeps its place in the layout but loses its + href, so it can no longer be navigated or clicked. +
+
Basic Link

Disabled Link
+
+ By default the underline only appears on hover. The Underlined parameter keeps it visible at all + states, maximizing the visual cue that the text is interactive, especially when the link sits inside body text. +
+
Underlined link
+
+ The NoUnderline parameter removes the underline at every state, including hover and active, + for places where the color alone or the surrounding context already marks the text as a link. +
+
NoUnderline link
+
+ The Target parameter controls the browsing context the link opens in; the BitLinkTarget + class provides the standard values. A _blank target automatically gets a noopener + rel attribute to protect the app from the opened page (see the Rel section below). +
+
Blank target link

Parent target link @@ -36,12 +58,43 @@ Top target link
- + +
+ When the link points to a file, the Download parameter tells the browser to save it instead of + navigating to it. Pass an empty string to keep the server-provided file name, or a value to suggest your own. + Browsers honor this only for same-origin, blob:, and data: URLs, so a cross-origin + link will simply navigate as usual. +
+
+ Download the bit logo +

+ Download with a custom file name +
+ + +
+ Without an Href, the link renders as a button styled like a link and OnClick is its whole action. + With an Href, OnClick still fires alongside the navigation, which is handy for tracking or + for extra work right before leaving. The StopPropagation parameter keeps the click from bubbling up + to clickable containers around the link. +
+
Click to navigate to the bit platform GitHub repo! +

+ Link with both Href and OnClick +
OnClick count: @clickCount
+
+
+ A clickable container (clicked @containerClickCount times): + Link with StopPropagation (clicked @linkClickCount times) +
- -
If you start the Href parameter with a # character, it'll look for the an element with that id and tries to scroll the view into that element.
+ +
+ If you start the Href parameter with a # character, it'll look for an element with that id and smoothly + scrolls the view into that element, without pushing the fragment into the browser's address bar. +


Go To End of this Article
@@ -86,13 +139,40 @@ Go To Start of this Article
- + +
+ The Rel parameter describes the relationship between the current document and the linked one, using + the BitLinkRels flags enum, so multiple values combine with the | operator. + When the Target is _blank and no opener-related rel is provided, noopener + is added automatically to keep the opened page from reaching back into the app. +
+
Link with a rel attribute (nofollow)

Link with a rel attribute (nofollow & noreferrer) +

+ Blank target link with an automatic noopener rel +
+ + +
+ A disabled link is normally removed from the tab order, which means keyboard and screen reader users can miss + that it exists at all. Setting AllowDisabledFocus conveys the disabled state through + aria-disabled instead, so the link stays focusable and discoverable while its navigation and + click action remain suppressed. Tab through the two links below to feel the difference. +
+
+ Disabled link (skipped by Tab) +

+ Disabled link with AllowDisabledFocus (focusable)
- + +
+ The NoColor parameter stops the link from applying any foreground color, letting the content keep + its own color, useful when the link wraps rich content that brings its own styling. +
+
Link with default color! this text color is coming from the link itself. @@ -103,7 +183,7 @@
- +
Offering a range of specialized color variants with Primary being the default, providing visual cues for specific actions or states within your application.

@@ -191,13 +271,22 @@
- + +
+ Since the link renders a single element, the root-level Style and Class parameters are all it + takes to restyle it, on top of the theme tokens it reads its colors from. +
+
Link with style

Link with class
- + +
+ Use the Dir parameter to render the link in right-to-left direction for RTL languages. +
+
پیوند راست به چپ
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.cs index 2fc5fa31eb1..0e296dd0311 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.cs @@ -7,6 +7,13 @@ public partial class BitLinkDemo private readonly List componentParameters = [ + new() + { + Name = "AllowDisabledFocus", + Type = "bool", + DefaultValue = "false", + Description = "Keeps the disabled link focusable and discoverable by assistive technologies, conveying the disabled state using the aria-disabled attribute.", + }, new() { Name = "ChildContent", @@ -24,18 +31,25 @@ public partial class BitLinkDemo Href = "#color-enum", }, new() + { + Name = "Download", + Type = "string?", + DefaultValue = "null", + Description = "The value of the download attribute of the link when the Href parameter is provided. Instructs the browser to download the linked resource instead of navigating to it, using the provided value (if any) as the suggested file name.", + }, + new() { Name = "Href", Type = "string?", DefaultValue = "null", - Description = "URL the link points to.", + Description = "URL the link points to. If provided, the component renders an anchor tag, otherwise a button. A value starting with the # character makes the link smooth-scroll the element with that id into view.", }, new() { Name = "NoColor", Type = "bool", DefaultValue = "false", - Description = "Removes the applying any foreground color to the link content.", + Description = "Removes applying any foreground color to the link content, letting it keep its own color.", }, new() { @@ -48,23 +62,30 @@ public partial class BitLinkDemo { Name = "OnClick", Type = "EventCallback", - Description = "Callback for when the link clicked.", + Description = "Callback for when the link is clicked. It is invoked in every render mode of the link: on anchor links it runs alongside the navigation, and on button links (no Href) it is the sole click action.", }, new() { Name = "Rel", Type = "BitLinkRels?", DefaultValue = "null", - Description = "If Href provided, specifies the relationship between the current document and the linked document.", + Description = "If Href provided, specifies the relationship between the current document and the linked document. Ignored for empty or hash-only (#) hrefs. When Target is _blank and no opener-related rel is provided, noopener is added automatically.", LinkType = LinkType.Link, Href = "#link-rels", }, new() + { + Name = "StopPropagation", + Type = "bool", + DefaultValue = "false", + Description = "If true, stops the propagation of the click event to the parent elements. Useful when the link is placed inside clickable containers like rows or cards.", + }, + new() { Name = "Target", Type = "string?", DefaultValue = "null", - Description = "If Href provided, specifies how to open the link.", + Description = "If Href provided, specifies how to open the link (e.g. _blank to open it in a new tab). When set to _blank and no opener-related Rel is provided, noopener is added to the rel attribute automatically.", LinkType = LinkType.Link, Href = "#link-target", }, @@ -77,6 +98,24 @@ public partial class BitLinkDemo }, ]; + private readonly List componentPublicMembers = + [ + new() + { + Name = "FocusAsync", + Type = "ValueTask", + DefaultValue = "", + Description = "Gives focus to the root element of the link. A disabled link is only focusable when AllowDisabledFocus keeps it in the tab order.", + }, + new() + { + Name = "FocusAsync(bool preventScroll)", + Type = "ValueTask", + DefaultValue = "", + Description = "Gives focus to the root element of the link, leaving the page scrolled where it is instead of bringing the link into view.", + }, + ]; + private readonly List componentSubClasses = [ new() @@ -323,6 +362,10 @@ public partial class BitLinkDemo + private int clickCount; + private int linkClickCount; + private int containerClickCount; + private void HandleOnClick() { Navigation.NavigateTo("https://github.com/bitfoundation/bitplatform"); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.samples.cs index 21b2016c55b..e69d0db4ad3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Link/BitLinkDemo.razor.samples.cs @@ -20,16 +20,32 @@ public partial class BitLinkDemo Top target link"; private readonly string example5RazorCode = @" -Click to navigate to the bitplatform GitHub repo!"; - private readonly string example5CsharpCode = @" +Download the bit logo +Download with a custom file name"; + + private readonly string example6RazorCode = @" +Click to navigate to the bit platform GitHub repo! + + clickCount++"" Target=""_blank"" Href=""https://github.com/bitfoundation/bitplatform"">Link with both Href and OnClick +
OnClick count: @clickCount
+ +
containerClickCount++""> + A clickable container (clicked @containerClickCount times): + linkClickCount++"">Link with StopPropagation (clicked @linkClickCount times) +
"; + private readonly string example6CsharpCode = @" [Inject] private NavigationManager Navigation { get; set; } = default!; +private int clickCount; +private int linkClickCount; +private int containerClickCount; + private void HandleOnClick() { Navigation.NavigateTo(""https://github.com/bitfoundation/bitplatform""); }"; - private readonly string example6RazorCode = @" + private readonly string example7RazorCode = @" Go To End of this Article
Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -57,26 +73,31 @@ each word has the power to transform into something extraordinary. Here lies the idea that sparks change, these lines are yours to fill, to shape, and to make uniquely yours. The journey begins here, in this quiet moment where everything is possible.
-Imagine this space as a window into the future empty yet alive with the energy of endless possibilities. -These words stand as temporary guides, placeholders that whisper of what is to come. -They hold the promise of stories waiting to unfold, ideas eager to take shape, and -connections that will soon emerge to inspire and resonate. This is not an empty page; +Imagine this space as a window into the future empty yet alive with the energy of endless possibilities. +These words stand as temporary guides, placeholders that whisper of what is to come. +They hold the promise of stories waiting to unfold, ideas eager to take shape, and +connections that will soon emerge to inspire and resonate. This is not an empty page; it is a canvas, rich with potential and ready to transform into something meaningful.
-For now, these lines are here to remind you of the beauty of beginnings. They are the quiet before the symphony, -the foundation upon which your creativity will build. Soon, this space will hold your thoughts, your visions, -and your voice a reflection of who you are and what you wish to share with the world. Every sentence will carry -purpose, every word will invite others to connect, to think, to feel. So take a moment to dream, to imagine -what this blank slate can become. Whether it’s a story, an idea, or a message that matters, this is your +For now, these lines are here to remind you of the beauty of beginnings. They are the quiet before the symphony, +the foundation upon which your creativity will build. Soon, this space will hold your thoughts, your visions, +and your voice a reflection of who you are and what you wish to share with the world. Every sentence will carry +purpose, every word will invite others to connect, to think, to feel. So take a moment to dream, to imagine +what this blank slate can become. Whether it’s a story, an idea, or a message that matters, this is your starting point. The possibilities are endless, and the journey begins now.
Go To Start of this Article"; - private readonly string example7RazorCode = @" + private readonly string example8RazorCode = @" Link with a rel attribute (nofollow) -Link with a rel attribute (nofollow & noreferrer)"; +Link with a rel attribute (nofollow & noreferrer) +Blank target link with an automatic noopener rel"; - private readonly string example8RazorCode = @" + private readonly string example9RazorCode = @" +Disabled link (skipped by Tab) +Disabled link with AllowDisabledFocus (focusable)"; + + private readonly string example10RazorCode = @" Link with default color! this text color is coming from the link itself. @@ -86,8 +107,8 @@ and your voice a reflection of who you are and what you wish to share with the w Link with NoColor! "; - private readonly string example9RazorCode = @" - Primary Color Link + private readonly string example11RazorCode = @" +Primary Color Link (default) Secondary Color Link Tertiary Color Link Info Color Link @@ -96,9 +117,11 @@ and your voice a reflection of who you are and what you wish to share with the w SevereWarning Color Link Error Color Link -PrimaryBackground Color Link -SecondaryBackground Color Link -TertiaryBackground Color Link +
+ PrimaryBackground Color Link + SecondaryBackground Color Link + TertiaryBackground Color Link +
PrimaryForeground Color Link SecondaryForeground Color Link @@ -132,7 +155,7 @@ and your voice a reflection of who you are and what you wish to share with the w SecondaryBorder TertiaryBorder"; - private readonly string example10RazorCode = @" + private readonly string example12RazorCode = @"