diff --git a/src/BlazorUI/Bit.BlazorUI/Components/BitLinkRels.cs b/src/BlazorUI/Bit.BlazorUI/Components/BitLinkRels.cs index feeefa5af70..78c432b57b2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/BitLinkRels.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/BitLinkRels.cs @@ -70,5 +70,36 @@ public enum BitLinkRels /// /// A tag (keyword) for the current document. /// - Tag = 4096 + Tag = 4096, + + /// + /// Indicates that the linked document represents the person who owns the current content. (used for identity verification) + /// + Me = 8192, + + /// + /// Requires that any browsing context created by following the hyperlink keeps its opener browsing context. + /// (reverses the implicit noopener modern browsers apply to _blank targets) + /// + Opener = 16384, + + /// + /// Links to the privacy policy that applies to the current document. (rendered as privacy-policy) + /// + PrivacyPolicy = 32768, + + /// + /// Marks the link as an advertisement or paid placement, so search engines do not count it as an organic endorsement. + /// + Sponsored = 65536, + + /// + /// Links to the terms of service that apply to the current document. (rendered as terms-of-service) + /// + TermsOfService = 131072, + + /// + /// Marks the link as user-generated content, like forum posts or comments, for search engines. + /// + Ugc = 262144 } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor index afd69d8a74d..857a71e3a58 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor @@ -1,45 +1,148 @@ @namespace Bit.BlazorUI @inherits BitComponentBase -@if (Href.HasValue()) +@{ + var isAnchor = Href.HasValue(); + + @* An in-page link scrolls rather than navigates, so it answers the click itself and never lets the + browser move the document. That also keeps the fragment out of the address bar and out of the + history, which is what separates a link into the page from a link to it. *@ + var isHash = isAnchor && Href!.StartsWith('#'); + + var icon = BitIconInfo.From(Icon, IconName); + + @* An attribute written after the splat wins over the one the app passed in through HtmlAttributes, and + a null one takes it off the element altogether. So every attribute the link writes for itself reads + what came in with the attributes first and writes it back when the parameter behind it was left + unset - otherwise a plain rel, target or title written on a BitLink would silently do nothing. *@ + var splattedRel = HtmlAttributes.TryGetValue("rel", out var srl) ? srl?.ToString() : null; + var splattedDir = HtmlAttributes.TryGetValue("dir", out var sdr) ? sdr?.ToString() : null; + var splattedRole = HtmlAttributes.TryGetValue("role", out var sro) ? sro?.ToString() : null; + var splattedTitle = HtmlAttributes.TryGetValue("title", out var stl) ? stl?.ToString() : null; + var splattedLabel = HtmlAttributes.TryGetValue("aria-label", out var sal) ? sal?.ToString() : null; + var splattedTarget = HtmlAttributes.TryGetValue("target", out var stg) ? stg?.ToString() : null; + var splattedTabIndex = HtmlAttributes.TryGetValue("tabindex", out var sti) ? sti?.ToString() : null; + var splattedDisabled = HtmlAttributes.TryGetValue("aria-disabled", out var sdd) ? sdd?.ToString() : null; + var splattedCurrent = HtmlAttributes.TryGetValue("aria-current", out var scr) ? scr?.ToString() : null; + var splattedLabelledBy = HtmlAttributes.TryGetValue("aria-labelledby", out var slb) ? slb?.ToString() : null; + var splattedDescribedBy = HtmlAttributes.TryGetValue("aria-describedby", out var sdb) ? sdb?.ToString() : null; + + @* The download attribute is the one of these that is also written without a value, so what came in with + the attributes is handed back as the object it arrived as: a bool renders the bare attribute, a string + renders the file name it names. *@ + var splattedDownload = HtmlAttributes.TryGetValue("download", out var sdl) ? sdl : null; + + var dir = Dir?.ToString().ToLower() ?? splattedDir; + var title = Title ?? splattedTitle; + var tabIndex = _tabIndex ?? splattedTabIndex; + var target = Target.HasValue() ? Target : splattedTarget; + var role = splattedRole ?? (IsEnabled ? null : "link"); + var ariaDisabled = IsEnabled ? splattedDisabled : "true"; + var ariaCurrent = GetAriaCurrent(splattedCurrent); + + @* Neither the target nor the download belongs on a link that cannot be followed, and neither means + anything on one that scrolls the page instead of navigating it. Everything downstream reads the + target that is actually on the element rather than the parameter, so a link that will not open a new + tab is not described as one either. *@ + var canNavigate = isAnchor && IsEnabled && isHash is false; + var download = canNavigate ? (Download is not null ? Download : splattedDownload) : null; + var renderedTarget = canNavigate ? target : null; + + @* A rel is document metadata rather than a navigation behaviour - it is what a crawler reads to learn + that a link is sponsored, or that the profile it points at is the author's - so a disabled link keeps + saying what it is a link to, and keeps the noopener that says what opening it must not hand over. *@ + var rel = isHash ? splattedRel : BuildRel(_rel ?? splattedRel, target); + + var newTabHint = GetNewTabHint(renderedTarget); + var label = AriaLabel ?? splattedLabel; + + @* The new-tab sentence has to land wherever the link's name is coming from, since a name given from + somewhere else replaces the content rather than adding to it. An aria-labelledby is the one that wins, + and it points at elements rather than holding text, so the sentence is rendered as an element of its + own and its id appended to the list; an aria-label holds the text itself, so the sentence is appended + to it; a link named by nothing but its own content gets the sentence as a sibling of that content, + hidden from the screen but not from a reader. *@ + var hintId = $"{_Id}-nth"; + var labelledByHint = newTabHint.HasValue() && splattedLabelledBy.HasValue(); + var ariaLabelledBy = labelledByHint ? $"{splattedLabelledBy} {hintId}" : splattedLabelledBy; + var ariaLabel = label.HasValue() && newTabHint.HasValue() && labelledByHint is false ? $"{label} {newTabHint}" : label; + var hintText = label.HasValue() && labelledByHint is false ? null : newTabHint; + + @* A description is read out after the name rather than as part of it, so it is an element of its own that + the link points at - and one the link points at beside whatever the app was already pointing it at. *@ + var describedById = $"{_Id}-dsc"; + var hasDescription = AriaDescription.HasValue(); + var ariaDescribedBy = hasDescription + ? (splattedDescribedBy.HasValue() ? $"{splattedDescribedBy} {describedById}" : describedById) + : splattedDescribedBy; + + var iconAtEnd = IconPosition == BitIconPosition.End; + + RenderFragment body = @ + @if (icon is not null && iconAtEnd is false) + { +