Skip to content

Commit e9139ad

Browse files
committed
Link Manager SDC + Web Component
1 parent 5d77517 commit e9139ad

7 files changed

Lines changed: 157 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ Fundamental building blocks that cannot be broken down further.
101101
| [Header](./components/03-organisms/header) | stable | **SDC** - header component |
102102
| [Drawer](./components/03-organisms/drawer) | experimental | **SDC + Web Component** - container component that functions as both off-canvas sidebars (menus, carts) and centered modal dialogs. |
103103
| [Carousel](./components/03-organisms/carousel) | experimental | **SDC + Web Component** - a lightweight and accessible content slider |
104+
105+
### Utilities
106+
| Component | Status | Description |
107+
|:------------------------------------------------------------------|:-------------|:--------------------------------------------------------------------------------------------|
108+
| [Link Manager](./components/00-base/01-primitives/link-manager) | experimental | **SDC + Web Component** - automatically manages external links |
109+
| [Sticky Header](./components/00-base/01-primitives/sticky-header) | experimental | **SDC + Web Component** - adds "Smart Sticky" functionality to any header or navigation bar |
110+
| [Reveal](./components/00-base/01-primitives/reveal) | experimental | **SDC + Web Component** - provides a "reveal on scroll" animation for its content |
111+
| [Focus Trap](./components/00-base/01-primitives/focus-trap) | stable | JS only component used by Drawer, Menu toggle |
112+
113+
104114
## Documentation
105115

106116
* **UI Library / Storybook:** [Setup and Usage Guide](./docs/storybook.md)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# 📁 Link Manager (SDC Component)
2+
3+
A modern Drupal **Single Directory Component** that leverages **Native Web Components** to automatically manage external links. It identifies external domains, enforces security attributes, and injects an SVG icon without disrupting the page's visual layout.
4+
5+
## Features
6+
* **Automatic:** Scans all `<a>` tags within the component's scope.
7+
* **Secure:** Automatically adds `target="_blank"` and `rel="noopener noreferrer"`.
8+
* **Encapsulated:** Uses **Shadow DOM** so icon styles won't conflict with your global CSS.
9+
* **Reactive:** Uses a `MutationObserver` to handle links loaded dynamically via AJAX (e.g., Views "Load More").
10+
* **Layout Friendly:** Uses `display: contents` to ensure the wrapper doesn't break Flexbox or Grid layouts.
11+
* **Smart Filtering:** Automatically ignores links containing images (`<img>`) or those marked with a `data-ignore` attribute.
12+
13+
---
14+
15+
## Implementation
16+
17+
### Using `embed` (The Standard Way)
18+
Wrap any block of content where you want to manage external links:
19+
20+
```twig
21+
{% embed 'wudo:link-manager' %}
22+
{% block content %}
23+
<p>
24+
Check out <a href="https://example.com">this external site</a> for more info.
25+
</p>
26+
<p>
27+
Internal link: <a href="/about-us">About Us</a>
28+
</p>
29+
{% endblock %}
30+
{% endembed %}
31+
```
32+
### Using `include` (For Single Links)
33+
For single links, you can use `include`:
34+
35+
```twig
36+
{{ include('wudo:link-manager', {}, with_context = false) }}
37+
<a href="https://example.com">External Site</a>
38+
{{ endinclude }}
39+
```
40+
41+
### Manual Bypass
42+
To prevent a specific external link from being processed, add the `data-ignore` attribute:
43+
```html
44+
<a href="[https://external.com](https://external.com)" data-ignore>External link without icon</a>
45+
```
46+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: Link Manager
2+
status: experimental
3+
group: Utilities
4+
5+
slots:
6+
content:
7+
title: Content
8+
description: "Content where links will be managed."
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* Don't affect layout */
2+
link-manager {
3+
display: contents;
4+
}
5+
6+
.ext-icon {
7+
display: inline-flex;
8+
margin-left: 0.35em;
9+
vertical-align: middle;
10+
width: var(--ext-link-icon-size, 1em);
11+
height: var(--ext-link-icon-size, 1em);
12+
line-height: 1;
13+
}
14+
15+
.ext-icon svg {
16+
width: 100%;
17+
height: 100%;
18+
fill: currentColor;
19+
display: block;
20+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
class LinkManager extends HTMLElement {
2+
constructor() {
3+
super();
4+
this.attachShadow({ mode: 'open' });
5+
}
6+
7+
connectedCallback() {
8+
this.shadowRoot.innerHTML = `<slot></slot>`;
9+
10+
this.processLinks(this);
11+
this.setupObserver();
12+
}
13+
14+
processLinks(container) {
15+
const links = container.tagName === 'A' ? [container] : container.querySelectorAll('a');
16+
const host = window.location.hostname;
17+
18+
links.forEach(link => {
19+
const isExternal = link.hostname && link.hostname !== host;
20+
21+
if (isExternal && !link.dataset.linkManaged && !link.hasAttribute('data-ignore') && !link.querySelector('img')) {
22+
link.dataset.linkManaged = 'true';
23+
link.setAttribute('target', '_blank');
24+
link.setAttribute('rel', 'noopener noreferrer');
25+
26+
const icon = document.createElement('span');
27+
icon.className = 'ext-icon';
28+
icon.innerHTML = `
29+
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
30+
<path d="M17.9199 6.62C17.8185 6.37565 17.6243 6.18147 17.3799 6.08C17.2597 6.02876 17.1306 6.00158 16.9999 6H6.99994C6.73472 6 6.48037 6.10536 6.29283 6.29289C6.1053 6.48043 5.99994 6.73478 5.99994 7C5.99994 7.26522 6.1053 7.51957 6.29283 7.70711C6.48037 7.89464 6.73472 8 6.99994 8H14.5899L6.28994 16.29C6.19621 16.383 6.12182 16.4936 6.07105 16.6154C6.02028 16.7373 5.99414 16.868 5.99414 17C5.99414 17.132 6.02028 17.2627 6.07105 17.3846C6.12182 17.5064 6.19621 17.617 6.28994 17.71C6.3829 17.8037 6.4935 17.8781 6.61536 17.9289C6.73722 17.9797 6.86793 18.0058 6.99994 18.0058C7.13195 18.0058 7.26266 17.9797 7.38452 17.9289C7.50638 17.8781 7.61698 17.8037 7.70994 17.71L15.9999 9.41V17C15.9999 17.2652 16.1053 17.5196 16.2928 17.7071C16.4804 17.8946 16.7347 18 16.9999 18C17.2652 18 17.5195 17.8946 17.707 17.7071C17.8946 17.5196 17.9999 17.2652 17.9999 17V7C17.9984 6.86932 17.9712 6.74022 17.9199 6.62Z" />
31+
</svg>`;
32+
33+
link.appendChild(icon);
34+
}
35+
});
36+
}
37+
38+
setupObserver() {
39+
this.observer = new MutationObserver(() => this.processLinks(this));
40+
this.observer.observe(this, { childList: true, subtree: true });
41+
}
42+
43+
disconnectedCallback() {
44+
if (this.observer) this.observer.disconnect();
45+
}
46+
}
47+
48+
if (!customElements.get('link-manager')) {
49+
customElements.define('link-manager', LinkManager);
50+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* Don't affect layout */
2+
link-manager {
3+
display: contents;
4+
}
5+
6+
.ext-icon {
7+
display: inline-flex;
8+
margin-left: 0.35em;
9+
vertical-align: middle;
10+
width: var(--ext-link-icon-size, 1em);
11+
height: var(--ext-link-icon-size, 1em);
12+
line-height: 1;
13+
}
14+
15+
.ext-icon svg {
16+
width: 100%;
17+
height: 100%;
18+
fill: currentColor;
19+
display: block;
20+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<link-manager>
2+
{{ content }}
3+
</link-manager>

0 commit comments

Comments
 (0)