Skip to content
Merged
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
33 changes: 32 additions & 1 deletion src/BlazorUI/Bit.BlazorUI/Components/BitLinkRels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,36 @@ public enum BitLinkRels
/// <summary>
/// A tag (keyword) for the current document.
/// </summary>
Tag = 4096
Tag = 4096,

/// <summary>
/// Indicates that the linked document represents the person who owns the current content. (used for identity verification)
/// </summary>
Me = 8192,

/// <summary>
/// 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)
/// </summary>
Opener = 16384,

/// <summary>
/// Links to the privacy policy that applies to the current document. (rendered as privacy-policy)
/// </summary>
PrivacyPolicy = 32768,

/// <summary>
/// Marks the link as an advertisement or paid placement, so search engines do not count it as an organic endorsement.
/// </summary>
Sponsored = 65536,

/// <summary>
/// Links to the terms of service that apply to the current document. (rendered as terms-of-service)
/// </summary>
TermsOfService = 131072,

/// <summary>
/// Marks the link as user-generated content, like forum posts or comments, for search engines.
/// </summary>
Ugc = 262144
}
171 changes: 137 additions & 34 deletions src/BlazorUI/Bit.BlazorUI/Components/Utilities/Link/BitLink.razor
Original file line number Diff line number Diff line change
@@ -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 = @<text>
@if (icon is not null && iconAtEnd is false)
{
<i class="bit-lnk-icn bit-lnk-sic @icon.GetCssClasses()" aria-hidden="true" />
}
@ChildContent
@if (icon is not null && iconAtEnd)
{
<i class="bit-lnk-icn bit-lnk-eic @icon.GetCssClasses()" aria-hidden="true" />
}
@if (hintText.HasValue())
{
<span id="@(labelledByHint ? hintId : null)" class="bit-lnk-hnt">@hintText</span>
}
</text>;
}

@if (isAnchor)
{
if (Href!.StartsWith('#'))
{
<a @ref="RootElement" @attributes="HtmlAttributes" @onclick="ScrollIntoView" @onclick:preventDefault
id="@_Id"
aria-label="@AriaLabel"
style="@StyleBuilder.Value"
class="@ClassBuilder.Value"
dir="@Dir?.ToString().ToLower()">
@ChildContent
</a>
}
else
{
<a @ref="RootElement" @attributes="HtmlAttributes"
id="@_Id"
rel="@_rel"
aria-label="@AriaLabel"
style="@StyleBuilder.Value"
class="@ClassBuilder.Value"
dir="@Dir?.ToString().ToLower()"
href="@(IsEnabled ? Href : null)"
target="@(IsEnabled && Target.HasValue() ? Target : null)">
@ChildContent
</a>
}
<a @ref="RootElement" @attributes="HtmlAttributes" @onclick="HandleClick"
@onclick:stopPropagation="StopPropagation"
@onclick:preventDefault="@(isHash || PreventDefault || IsEnabled is false)"
id="@_Id"
dir="@dir"
rel="@rel"
role="@role"
title="@title"
tabindex="@tabIndex"
autofocus="@AutoFocus"
aria-label="@ariaLabel"
style="@StyleBuilder.Value"
class="@ClassBuilder.Value"
download="@download"
href="@(IsEnabled ? Href : null)"
target="@renderedTarget"
aria-current="@ariaCurrent"
aria-disabled="@ariaDisabled"
aria-labelledby="@ariaLabelledBy"
aria-describedby="@ariaDescribedBy">
@body
</a>
}
else
{
<button @ref="RootElement" @attributes="HtmlAttributes" @onclick="HandleClick"
<button @ref="RootElement" @attributes="HtmlAttributes" @onclick="HandleClick" @onclick:stopPropagation="StopPropagation"
id="@_Id"
aria-label="@AriaLabel"
dir="@dir"
type="button"
title="@title"
tabindex="@tabIndex"
autofocus="@AutoFocus"
aria-label="@ariaLabel"
style="@StyleBuilder.Value"
class="@ClassBuilder.Value"
dir="@Dir?.ToString().ToLower()"
type="button"
disabled="@(IsEnabled is false)"
aria-disabled="@(IsEnabled is false)">
@ChildContent
aria-current="@ariaCurrent"
aria-describedby="@ariaDescribedBy"
disabled="@(IsEnabled is false && AllowDisabledFocus is false)"
aria-disabled="@ariaDisabled">
@body
</button>
}
}

@* Rendered beside the link rather than inside it, so the description drives aria-describedby without becoming
part of the accessible name the link's own content already is. *@
@if (hasDescription)
{
<span id="@describedById" class="bit-lnk-dsc">@AriaDescription</span>
}
Loading
Loading