add external link UX: icons, rel attributes, screen reader labels

New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-04 00:55:09 +00:00
parent 696843bacd
commit 156a23da16
12 changed files with 58 additions and 30 deletions

View File

@ -64,7 +64,7 @@ Based on usability testing (March 2026). Reworks the entire setup flow into a si
| E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | planned | | E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | planned |
| F | Dashboard checklist and messaging rework | 2h | planned | | F | Dashboard checklist and messaging rework | 2h | planned |
| G | Coming soon page fixes (logo layout, admin login link) | 30m | done | | G | Coming soon page fixes (logo layout, admin login link) | 30m | done |
| H | External links UX (new tabs, icons, aria labels) | 1h | planned | | H | External links UX (new tabs, icons, aria labels) | 1h | done |
| I | Input styling — WCAG AA/AAA compliance | 1h | done | | I | Input styling — WCAG AA/AAA compliance | 1h | done |
### Notification system overhaul ([plan](docs/plans/notification-overhaul.md)) ### Notification system overhaul ([plan](docs/plans/notification-overhaul.md))

View File

@ -4774,6 +4774,14 @@
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ml-1 { margin-inline-start: 0.25rem; } .ml-1 { margin-inline-start: 0.25rem; }
.external-link-icon {
width: 0.75em;
height: 0.75em;
margin-inline-start: 0.25em;
vertical-align: baseline;
opacity: 0.6;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;

View File

@ -14,6 +14,14 @@
border-width: 0; border-width: 0;
} }
.external-link-icon {
width: 0.75em;
height: 0.75em;
margin-inline-start: 0.25em;
vertical-align: baseline;
opacity: 0.6;
}
.truncate { .truncate {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@ -423,6 +423,25 @@ defmodule BerrypodWeb.CoreComponents do
""" """
end end
@doc """
Renders a link to an external site with proper security attributes,
an external-link icon, and screen reader context.
"""
attr :href, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def external_link(assigns) do
~H"""
<a href={@href} target="_blank" rel="noopener noreferrer" class={@class} {@rest}>
{render_slot(@inner_block)}
<.icon name="hero-arrow-top-right-on-square" class="external-link-icon" />
<span class="sr-only">(opens in new tab)</span>
</a>
"""
end
## JS Commands ## JS Commands
def show(js \\ %JS{}, selector) do def show(js \\ %JS{}, selector) do
@ -608,9 +627,10 @@ defmodule BerrypodWeb.CoreComponents do
:if={@option[:url]} :if={@option[:url]}
href={@option.url} href={@option.url}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
class="card-radio-link" class="card-radio-link"
onclick="event.stopPropagation();" onclick="event.stopPropagation();"
aria-label={@option.name <> " (opens in new tab)"}
> >
{@option.name} &nearr; {@option.name} &nearr;
</a> </a>

View File

@ -431,6 +431,7 @@ defmodule BerrypodWeb.ShopComponents.Content do
<.social_icon platform={link.platform} /> <.social_icon platform={link.platform} />
</span> </span>
<span>{link.label}</span> <span>{link.label}</span>
<span class="sr-only">(opens in new tab)</span>
</a> </a>
<% end %> <% end %>
</div> </div>

View File

@ -272,8 +272,9 @@ defmodule BerrypodWeb.Admin.EmailSettings do
:if={adapter.url} :if={adapter.url}
href={adapter.url} href={adapter.url}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
class="admin-link-subtle admin-adapter-link" class="admin-link-subtle admin-adapter-link"
aria-label={adapter.name <> " website (opens in new tab)"}
> >
&nearr; &nearr;
</a> </a>

View File

@ -134,15 +134,13 @@ defmodule BerrypodWeb.Admin.OrderShow do
> >
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span> <span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span class="admin-text-medium">{@order.tracking_number}</span> <span class="admin-text-medium">{@order.tracking_number}</span>
<a <.external_link
:if={@order.tracking_url not in [nil, ""]} :if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url} href={@order.tracking_url}
target="_blank"
rel="noopener"
class="admin-link" class="admin-link"
> >
Track shipment &rarr; Track shipment
</a> </.external_link>
</div> </div>
<.order_timeline entries={@timeline} /> <.order_timeline entries={@timeline} />
</div> </div>

View File

@ -102,11 +102,12 @@ defmodule BerrypodWeb.Admin.ProductShow do
:if={provider_edit_url(@product)} :if={provider_edit_url(@product)}
href={provider_edit_url(@product)} href={provider_edit_url(@product)}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
class="admin-btn admin-btn-ghost admin-btn-sm" class="admin-btn admin-btn-ghost admin-btn-sm"
> >
Edit on {provider_label(@product)} Edit on {provider_label(@product)}
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
<span class="sr-only">(opens in new tab)</span>
</.link> </.link>
<.link <.link
navigate={~p"/products/#{@product.slug}"} navigate={~p"/products/#{@product.slug}"}

View File

@ -15,10 +15,10 @@
<p class="admin-callout-title">Get your API key from {@provider.name}:</p> <p class="admin-callout-title">Get your API key from {@provider.name}:</p>
<ol class="admin-callout-list"> <ol class="admin-callout-list">
<li> <li>
<a href={@provider.login_url} target="_blank" rel="noopener" class="admin-link"> <.external_link href={@provider.login_url} class="admin-link">
Log in to {@provider.name} Log in to {@provider.name}
</a> </.external_link>
(or <a href={@provider.signup_url} target="_blank" rel="noopener" class="admin-link">create a free account</a>) (or <.external_link href={@provider.signup_url} class="admin-link">create a free account</.external_link>)
</li> </li>
<li :for={step <- @provider.setup_steps}> <li :for={step <- @provider.setup_steps}>
{raw(step)} {raw(step)}

View File

@ -592,14 +592,9 @@ defmodule BerrypodWeb.Admin.Settings do
<p class="admin-section-desc admin-section-desc-flush"> <p class="admin-section-desc admin-section-desc-flush">
To accept payments, connect your Stripe account by entering your secret key. To accept payments, connect your Stripe account by entering your secret key.
You can find it in your You can find it in your
<a <.external_link href="https://dashboard.stripe.com/apikeys" class="admin-link">
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="admin-link"
>
Stripe dashboard Stripe dashboard
</a> </.external_link>
under Developers &rarr; API keys. under Developers &rarr; API keys.
</p> </p>

View File

@ -392,9 +392,9 @@ defmodule BerrypodWeb.Setup.Onboarding do
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %> <% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>
<p class="setup-hint"> <p class="setup-hint">
{provider_info.setup_hint}. {provider_info.setup_hint}.
<a href={provider_info.setup_url} target="_blank" rel="noopener" class="setup-link"> <.external_link href={provider_info.setup_url} class="setup-link">
Open {provider_info.name} &rarr; Open {provider_info.name}
</a> </.external_link>
</p> </p>
<.form for={@form} phx-submit="connect_provider"> <.form for={@form} phx-submit="connect_provider">
@ -428,14 +428,9 @@ defmodule BerrypodWeb.Setup.Onboarding do
<div> <div>
<p class="setup-hint"> <p class="setup-hint">
Enter your Stripe secret key to accept payments. Enter your Stripe secret key to accept payments.
<a <.external_link href="https://dashboard.stripe.com/apikeys" class="setup-link">
href="https://dashboard.stripe.com/apikeys" Open Stripe dashboard
target="_blank" </.external_link>
rel="noopener"
class="setup-link"
>
Open Stripe dashboard &rarr;
</a>
</p> </p>
<.form for={@form} phx-submit="connect_stripe"> <.form for={@form} phx-submit="connect_stripe">

View File

@ -888,6 +888,7 @@ defmodule BerrypodWeb.PageRenderer do
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="order-detail-tracking-btn themed-button" class="order-detail-tracking-btn themed-button"
aria-label="Track parcel (opens in new tab)"
> >
Track parcel Track parcel
</a> </a>