Repository: Automattic/wp-calypso
Pull request #103745: Help Center: add external link icons for links opening in new tabs
Author: login: escapemanuele
Created: 2025-05-27T14:30:07Z | Merged: 2025-05-29T16:35:05Z

Fixes https://linear.app/a8c/issue/DOTCOM-10695/help-center-articles-linkto-new-tab-should-include-an-external-link

<!--
Link a related Github/Linear issue to this PR. If the PR does not immediately resolve the issue,
for example, it requires a separate deployment to production, avoid
using the "fixes" keyword. Consider using "part of" instead.
-->

## Proposed Changes

  1. Added external link detection logic in packages/help-center/src/hooks/use-content-filter.ts that identifies links with target="_blank" and adds a visual icon
  (↗) and screen reader text
  2. Added CSS styles in packages/help-center/src/components/help-center-article.scss for the external link icon with proper spacing, color, and accessibility support

  - Automatically detects links that open in new tabs (target="_blank")
  - Adds a ↗ symbol next to these links
  - Includes screen reader text "(opens in a new tab)" for accessibility
  - Uses design system colors (--color-neutral-60)
  - Prevents duplicate icons on already-processed link

## Testing Instructions

<!--
Add as many details as possible to help others reproduce the issue and test the fix.
"Before / After" screenshots can also be very helpful when the change is visual.
-->

* Open Help Center and check an article for external links




Diff:
diff --git a/packages/help-center/src/components/help-center-article.scss b/packages/help-center/src/components/help-center-article.scss
index 2384b5c1563b..824bf47b653a 100644
--- a/packages/help-center/src/components/help-center-article.scss
+++ b/packages/help-center/src/components/help-center-article.scss
@@ -558,6 +558,23 @@
 					}
 				}
 			}
+
+			// External link icon styles
+			.help-center-external-link-icon {
+				color: currentColor;
+				fill: currentColor;
+				position: relative;
+				vertical-align: sub;
+			}
+
+			// Screen reader text for accessibility
+			.screen-reader-text {
+				clip: rect(1px, 1px, 1px, 1px);
+				position: absolute !important;
+				height: 1px;
+				width: 1px;
+				overflow: hidden;
+			}
 		}
 	}
 
diff --git a/packages/help-center/src/hooks/use-content-filter.ts b/packages/help-center/src/hooks/use-content-filter.ts
index df97d5b76dbe..57e89a3cb46b 100644
--- a/packages/help-center/src/hooks/use-content-filter.ts
+++ b/packages/help-center/src/hooks/use-content-filter.ts
@@ -1,4 +1,5 @@
 import { useEffect, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useHelpCenterContext } from '../contexts/HelpCenterContext';
 
@@ -95,6 +96,44 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
+				action: ( element: HTMLAnchorElement ) => {
+					const href = element.getAttribute( 'href' ) as string;
+
+					// Skip support articles
+					if ( href && isThisASupportArticleLink( href ) ) {
+						return;
+					}
+
+					// Create external link icon SVG
+					const icon = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
+					icon.setAttribute( 'class', 'help-center-external-link-icon' );
+					icon.setAttribute( 'width', '16' );
+					icon.setAttribute( 'height', '16' );
+					icon.setAttribute( 'viewBox', '0 0 24 24' );
+					icon.setAttribute( 'aria-hidden', 'true' );
+
+					const path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
+					path.setAttribute(
+						'd',
+						'M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z'
+					);
+					icon.appendChild( path );
+
+					// Add screen reader text for accessibility
+					const srText = document.createElement( 'span' );
+					srText.className = 'screen-reader-text';
+					srText.textContent = ` (${ __( 'opens in a new tab', 'help-center' ) })`;
+
+					// Append icon and screen reader text to the link
+					element.appendChild( icon );
+					element.appendChild( srText );
+				},
+			},
 		],
 		[ navigate, link, node ]
 	);


Reviews:
user: login: BogdanUngureanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T14:45:56Z
user: login: alshakero
body: Found one issue.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-27T14:47:43Z
user: login: BogdanUngureanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T14:49:48Z
user: login: escapemanuele
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T15:17:40Z
user: login: alshakero
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-27T15:27:05Z
user: login: alshakero
body: Looks great. I would add `vertical-align: text-top` to align it with the text.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-27T17:26:24Z

Review comments:
diff_hunk: @@ -95,6 +95,33 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
+				action: ( element: HTMLAnchorElement ) => {
+					// Skip if already has an external icon
+					if ( element.querySelector( '.help-center-external-link-icon' ) ) {
+						return;
+					}
+
+					// Create external link icon
+					const icon = document.createElement( 'span' );
+					icon.className = 'help-center-external-link-icon';
+					icon.setAttribute( 'aria-hidden', 'true' );
+					icon.innerHTML = '↗';
+
+					// Add screen reader text for accessibility
+					const srText = document.createElement( 'span' );
+					srText.className = 'screen-reader-text';
+					srText.textContent = ' (opens in a new tab)';
path: packages/help-center/src/hooks/use-content-filter.ts
user: login: BogdanUngureanu
body: Should this be translated? :) 
created_at: 2025-05-27T14:45:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +95,33 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
path: packages/help-center/src/hooks/use-content-filter.ts
user: login: alshakero
body: Apparently, support links also have target="_blank", we should use `rel="noopener"` instead. You can see an arrow was added to every link. I found an external link in "Connect a GoDaddy Domain" article under `Option 1: Simple Setup`, there is a [Domain Connect standard](https://www.domainconnect.org/) link.

Also, I think the arrow needs some margin or white space before it.
created_at: 2025-05-27T14:46:57Z
author_association: MEMBER
diff_hunk: @@ -95,6 +95,33 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
+				action: ( element: HTMLAnchorElement ) => {
+					// Skip if already has an external icon
+					if ( element.querySelector( '.help-center-external-link-icon' ) ) {
+						return;
+					}
+
+					// Create external link icon
+					const icon = document.createElement( 'span' );
+					icon.className = 'help-center-external-link-icon';
+					icon.setAttribute( 'aria-hidden', 'true' );
+					icon.innerHTML = '↗';
path: packages/help-center/src/hooks/use-content-filter.ts
user: login: BogdanUngureanu
body: Can we use a html code or something else? According to google: 
```
UNICODEU+02197
HEX CODE&#x2197;
HTML CODE&#8599;
HTML ENTITY&nearr;
CSS CODE\2197
```
created_at: 2025-05-27T14:49:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +95,33 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
path: packages/help-center/src/hooks/use-content-filter.ts
user: login: escapemanuele
body: I excluded support articles in the code instead 👍 
created_at: 2025-05-27T15:17:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +96,44 @@ export const useContentFilter = ( node: HTMLDivElement | null ) => {
 					}
 				},
 			},
+			/**
+			 * Add external link icons to links that open in new tabs.
+			 */
+			{
+				pattern: 'a[target="_blank"]',
+				action: ( element: HTMLAnchorElement ) => {
+					const href = element.getAttribute( 'href' ) as string;
+
+					// Skip support articles
+					if ( href && isThisASupportArticleLink( href ) ) {
+						return;
+					}
+
+					// Create external link icon SVG
+					const icon = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
+					icon.setAttribute( 'class', 'help-center-external-link-icon' );
+					icon.setAttribute( 'width', '18' );
+					icon.setAttribute( 'height', '18' );
+					icon.setAttribute( 'viewBox', '0 0 24 24' );
+					icon.setAttribute( 'aria-hidden', 'true' );
+
+					const path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
+					path.setAttribute(
+						'd',
+						'M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z'
+					);
+					icon.appendChild( path );
path: packages/help-center/src/hooks/use-content-filter.ts
user: login: alshakero
body: 100% optional. I would throw this in an `:after` element instead. 

I also noticed that it's rendering gray (probably the path needs a fill) and is a tad too big

![image](https://github.com/user-attachments/assets/cdb58907-ad48-4d79-832c-67fe05d5f8c7)

created_at: 2025-05-27T15:24:16Z
author_association: MEMBER
diff_hunk: @@ -558,6 +558,25 @@
 					}
 				}
 			}
+
+			// External link icon styles
+			.help-center-external-link-icon {
+				color: currentColor;
+				fill: currentColor;
+				margin-left: 3px;
+				margin-right: 0;
+				top: 2px;
+				position: relative;
path: packages/help-center/src/components/help-center-article.scss
user: login: alshakero
body: ```suggestion
				position: relative;
				vertical-align: text-top;
```
created_at: 2025-05-27T17:26:03Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-27T14:30:22Z
body: <details>
<summary>Calypso Live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-146238%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238">https://calypso.live?image=registry.a8c.com/calypso/app:build-146238</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Jetpack Cloud live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=jetpack">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-146238%26env%3Djetpack%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=jetpack">https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=jetpack</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Automattic for Agencies live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=a8c-for-agencies">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-146238%26env%3Da8c-for-agencies%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=a8c-for-agencies">https://calypso.live?image=registry.a8c.com/calypso/app:build-146238&env=a8c-for-agencies</a>
</td>
</tr>
</table>
</details>
<!--calypso-live-watermark:apr@v1-->
author_association: NONE
user: login: matticbot
created_at: 2025-05-27T14:36:33Z
body: **This PR modifies the release build for the following Calypso Apps:**

_For info about this notification, see here: PCYsg-OT6-p2_

* blaze-dashboard
* command-palette-wp-admin
* help-center
* notifications
* odyssey-stats

To test WordPress.com changes, run `install-plugin.sh $pluginSlug help-center/external-link-icons` on your sandbox.
<!--calypso-app-artifacts-watermark:apr@v1-->
author_association: CONTRIBUTOR
user: login: matticbot
created_at: 2025-05-27T14:39:56Z
body: <!-- icfy-watermark: c52822 -->
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

**Async-loaded Components** (~300 bytes added 📈 [gzipped])
<details>

```
name                                       parsed_size           gzip_size
async-load-automattic-help-center-stepper       +853 B  (+0.1%)     +300 B  (+0.1%)
async-load-automattic-help-center               +853 B  (+0.1%)     +300 B  (+0.2%)
```

React components that are loaded lazily, when a certain part of UI is displayed for the first time.
</details>

**Legend**
<details>
<summary>What is parsed and gzip size?</summary>

**Parsed Size:** Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
**Gzip Size:** Compressed size of the JS and CSS files. This much data needs to be downloaded over network.
</details>

Generated by performance advisor bot at [iscalypsofastyet.com](http://iscalypsofastyet.com).
author_association: CONTRIBUTOR
user: login: escapemanuele
created_at: 2025-05-27T15:18:31Z
body: Thank you @alshakero and @BogdanUngureanu, I should have opened a draft but I didn't :D 

Could you give this another look?

Also @fditrapani, can you check the external icon?
author_association: CONTRIBUTOR
user: login: escapemanuele
created_at: 2025-05-27T16:00:47Z
body: Makes sense @alshakero, I addressed your feedback!

Let's hear if @fditrapani has something to say regarding the style
author_association: CONTRIBUTOR
user: login: fditrapani
created_at: 2025-05-27T17:11:34Z
body: Thanks for the ping @escapemanuele. The icon is right! The alignment seems slightly off. 

<img width="1839" alt="image" src="https://github.com/user-attachments/assets/ec7a3ebb-cdd1-4cda-9a03-2872bfe62547" />

here's what it looks like if we use an inline-flex item vs. the relative positioning:

<img width="1720" alt="image" src="https://github.com/user-attachments/assets/e85b4baf-449e-49f7-8c3b-61e24254c303" />

(Note: I also removed the margins and position on the svg)

author_association: CONTRIBUTOR
user: login: a8ci18n
created_at: 2025-05-29T18:20:01Z
body: This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/17524690

Some locales (Hebrew, Japanese) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday.

Hi @escapemanuele, could you please edit the description of this PR and add a screenshot for our translators? Ideally it'd include this string: `opens in a new tab`

Thank you in advance!
author_association: NONE
user: login: a8ci18n
created_at: 2025-06-01T10:29:05Z
body: Translation for this Pull Request [has now been finished](https://translate.wordpress.com/deliverables/overview/17524690).
author_association: NONE