@livenetworks/ashlar 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -0
- package/js/COMPONENTS.md +1102 -0
- package/js/index.js +41 -0
- package/js/ln-accordion/README.md +137 -0
- package/js/ln-accordion/ln-accordion.js +1 -0
- package/js/ln-accordion/src/ln-accordion.js +41 -0
- package/js/ln-ajax/README.md +91 -0
- package/js/ln-ajax/ln-ajax.js +1 -0
- package/js/ln-ajax/src/ln-ajax.js +277 -0
- package/js/ln-api-connector/README.md +150 -0
- package/js/ln-api-connector/ln-api-connector.js +1 -0
- package/js/ln-api-connector/src/ln-api-connector.js +265 -0
- package/js/ln-autoresize/README.md +80 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -0
- package/js/ln-autoresize/src/ln-autoresize.js +47 -0
- package/js/ln-autosave/README.md +92 -0
- package/js/ln-autosave/ln-autosave.js +1 -0
- package/js/ln-autosave/src/ln-autosave.js +147 -0
- package/js/ln-circular-progress/README.md +161 -0
- package/js/ln-circular-progress/ln-circular-progress.js +1 -0
- package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
- package/js/ln-confirm/README.md +86 -0
- package/js/ln-confirm/_ln-confirm.scss +13 -0
- package/js/ln-confirm/ln-confirm.js +1 -0
- package/js/ln-confirm/src/ln-confirm.js +131 -0
- package/js/ln-core/crypto.js +83 -0
- package/js/ln-core/helpers.js +411 -0
- package/js/ln-core/index.js +5 -0
- package/js/ln-core/persist.js +71 -0
- package/js/ln-core/positioning.js +207 -0
- package/js/ln-core/reactive.js +74 -0
- package/js/ln-couchdb-connector/README.md +156 -0
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
- package/js/ln-data-coordinator/README.md +165 -0
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
- package/js/ln-data-store/README.md +94 -0
- package/js/ln-data-store/ln-data-store.js +1 -0
- package/js/ln-data-store/src/ln-data-store.js +699 -0
- package/js/ln-data-table/README.md +110 -0
- package/js/ln-data-table/ln-data-table.js +1 -0
- package/js/ln-data-table/ln-data-table.scss +10 -0
- package/js/ln-data-table/src/ln-data-table.js +1103 -0
- package/js/ln-date/README.md +151 -0
- package/js/ln-date/ln-date.js +1 -0
- package/js/ln-date/src/ln-date.js +442 -0
- package/js/ln-dropdown/README.md +117 -0
- package/js/ln-dropdown/ln-dropdown.js +1 -0
- package/js/ln-dropdown/ln-dropdown.scss +15 -0
- package/js/ln-dropdown/src/ln-dropdown.js +174 -0
- package/js/ln-external-links/README.md +341 -0
- package/js/ln-external-links/ln-external-links.js +1 -0
- package/js/ln-external-links/src/ln-external-links.js +116 -0
- package/js/ln-filter/README.md +99 -0
- package/js/ln-filter/ln-filter.js +1 -0
- package/js/ln-filter/ln-filter.scss +7 -0
- package/js/ln-filter/src/ln-filter.js +404 -0
- package/js/ln-form/README.md +101 -0
- package/js/ln-form/ln-form.js +1 -0
- package/js/ln-form/src/ln-form.js +199 -0
- package/js/ln-http/README.md +89 -0
- package/js/ln-http/ln-http.js +1 -0
- package/js/ln-http/src/ln-http.js +219 -0
- package/js/ln-icons/README.md +88 -0
- package/js/ln-icons/ln-icons.js +1 -0
- package/js/ln-icons/src/ln-icons.js +169 -0
- package/js/ln-link/README.md +303 -0
- package/js/ln-link/ln-link.js +1 -0
- package/js/ln-link/src/ln-link.js +196 -0
- package/js/ln-modal/README.md +154 -0
- package/js/ln-modal/ln-modal.js +1 -0
- package/js/ln-modal/ln-modal.scss +11 -0
- package/js/ln-modal/src/ln-modal.js +201 -0
- package/js/ln-nav/README.md +70 -0
- package/js/ln-nav/ln-nav.js +1 -0
- package/js/ln-nav/src/ln-nav.js +177 -0
- package/js/ln-number/README.md +122 -0
- package/js/ln-number/ln-number.js +1 -0
- package/js/ln-number/src/ln-number.js +302 -0
- package/js/ln-popover/README.md +127 -0
- package/js/ln-popover/ln-popover.js +1 -0
- package/js/ln-popover/src/ln-popover.js +288 -0
- package/js/ln-progress/README.md +442 -0
- package/js/ln-progress/ln-progress.js +1 -0
- package/js/ln-progress/src/ln-progress.js +150 -0
- package/js/ln-search/README.md +83 -0
- package/js/ln-search/ln-search.js +1 -0
- package/js/ln-search/ln-search.scss +7 -0
- package/js/ln-search/src/ln-search.js +114 -0
- package/js/ln-sortable/README.md +95 -0
- package/js/ln-sortable/ln-sortable.js +1 -0
- package/js/ln-sortable/src/ln-sortable.js +203 -0
- package/js/ln-table/README.md +101 -0
- package/js/ln-table/ln-table-sort.js +1 -0
- package/js/ln-table/ln-table.js +1 -0
- package/js/ln-table/ln-table.scss +11 -0
- package/js/ln-table/src/ln-table-sort.js +168 -0
- package/js/ln-table/src/ln-table.js +473 -0
- package/js/ln-tabs/README.md +137 -0
- package/js/ln-tabs/ln-tabs.js +1 -0
- package/js/ln-tabs/src/ln-tabs.js +171 -0
- package/js/ln-time/README.md +81 -0
- package/js/ln-time/ln-time.js +1 -0
- package/js/ln-time/src/ln-time.js +192 -0
- package/js/ln-toast/README.md +122 -0
- package/js/ln-toast/ln-toast.js +15 -0
- package/js/ln-toast/src/ln-toast.js +210 -0
- package/js/ln-toast/template.html +14 -0
- package/js/ln-toggle/README.md +137 -0
- package/js/ln-toggle/ln-toggle.js +1 -0
- package/js/ln-toggle/src/ln-toggle.js +139 -0
- package/js/ln-tooltip/README.md +58 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -0
- package/js/ln-tooltip/ln-tooltip.scss +9 -0
- package/js/ln-tooltip/src/ln-tooltip.js +169 -0
- package/js/ln-translations/README.md +96 -0
- package/js/ln-translations/ln-translations.js +1 -0
- package/js/ln-translations/src/ln-translations.js +275 -0
- package/js/ln-upload/README.md +180 -0
- package/js/ln-upload/ln-upload.js +1 -0
- package/js/ln-upload/ln-upload.scss +20 -0
- package/js/ln-upload/src/ln-upload.js +407 -0
- package/js/ln-validate/README.md +108 -0
- package/js/ln-validate/ln-validate.js +1 -0
- package/js/ln-validate/src/ln-validate.js +160 -0
- package/package.json +55 -0
- package/scss/base/_global.scss +83 -0
- package/scss/base/_reset.scss +17 -0
- package/scss/base/_typography.scss +125 -0
- package/scss/components/_accordion.scss +34 -0
- package/scss/components/_ajax.scss +15 -0
- package/scss/components/_alert.scss +5 -0
- package/scss/components/_app-shell.scss +15 -0
- package/scss/components/_avatar.scss +6 -0
- package/scss/components/_breadcrumbs.scss +33 -0
- package/scss/components/_button.scss +20 -0
- package/scss/components/_card.scss +10 -0
- package/scss/components/_chip.scss +5 -0
- package/scss/components/_circular-progress.scss +29 -0
- package/scss/components/_confirm.scss +5 -0
- package/scss/components/_data-table.scss +83 -0
- package/scss/components/_dropdown.scss +25 -0
- package/scss/components/_empty-state.scss +22 -0
- package/scss/components/_form.scss +100 -0
- package/scss/components/_layout.scss +8 -0
- package/scss/components/_link.scss +11 -0
- package/scss/components/_ln-table.scss +60 -0
- package/scss/components/_loader.scss +6 -0
- package/scss/components/_modal.scss +20 -0
- package/scss/components/_nav.scss +9 -0
- package/scss/components/_page-header.scss +10 -0
- package/scss/components/_popover.scss +10 -0
- package/scss/components/_progress.scss +17 -0
- package/scss/components/_prose.scss +5 -0
- package/scss/components/_scrollbar.scss +32 -0
- package/scss/components/_sections.scss +12 -0
- package/scss/components/_sidebar.scss +5 -0
- package/scss/components/_stat-card.scss +5 -0
- package/scss/components/_status-badge.scss +4 -0
- package/scss/components/_stepper.scss +5 -0
- package/scss/components/_table.scss +19 -0
- package/scss/components/_tabs.scss +21 -0
- package/scss/components/_timeline.scss +14 -0
- package/scss/components/_toast.scss +41 -0
- package/scss/components/_toggle.scss +81 -0
- package/scss/components/_tooltip.scss +18 -0
- package/scss/components/_translations.scss +111 -0
- package/scss/components/_upload.scss +51 -0
- package/scss/config/_breakpoints.scss +72 -0
- package/scss/config/_density.scss +117 -0
- package/scss/config/_icons.scss +37 -0
- package/scss/config/_mixins.scss +13 -0
- package/scss/config/_theme.scss +216 -0
- package/scss/config/_tokens.scss +419 -0
- package/scss/config/mixins/_accordion.scss +52 -0
- package/scss/config/mixins/_ajax.scss +39 -0
- package/scss/config/mixins/_alert.scss +82 -0
- package/scss/config/mixins/_app-shell.scss +312 -0
- package/scss/config/mixins/_avatar.scss +109 -0
- package/scss/config/mixins/_borders.scss +36 -0
- package/scss/config/mixins/_breadcrumbs.scss +72 -0
- package/scss/config/mixins/_breakpoints.scss +62 -0
- package/scss/config/mixins/_btn.scss +179 -0
- package/scss/config/mixins/_card.scss +338 -0
- package/scss/config/mixins/_chip.scss +66 -0
- package/scss/config/mixins/_circular-progress.scss +71 -0
- package/scss/config/mixins/_collapsible.scss +24 -0
- package/scss/config/mixins/_colors.scss +46 -0
- package/scss/config/mixins/_confirm.scss +31 -0
- package/scss/config/mixins/_data-table.scss +346 -0
- package/scss/config/mixins/_display.scss +32 -0
- package/scss/config/mixins/_dropdown.scss +143 -0
- package/scss/config/mixins/_empty-state.scss +30 -0
- package/scss/config/mixins/_focus.scss +55 -0
- package/scss/config/mixins/_footer.scss +42 -0
- package/scss/config/mixins/_form.scss +601 -0
- package/scss/config/mixins/_index.scss +58 -0
- package/scss/config/mixins/_interaction.scss +15 -0
- package/scss/config/mixins/_kbd.scss +22 -0
- package/scss/config/mixins/_layout.scss +117 -0
- package/scss/config/mixins/_link.scss +55 -0
- package/scss/config/mixins/_ln-table.scss +420 -0
- package/scss/config/mixins/_loader.scss +26 -0
- package/scss/config/mixins/_modal.scss +66 -0
- package/scss/config/mixins/_motion.scss +19 -0
- package/scss/config/mixins/_nav.scss +273 -0
- package/scss/config/mixins/_page-header.scss +69 -0
- package/scss/config/mixins/_popover.scss +25 -0
- package/scss/config/mixins/_position.scss +32 -0
- package/scss/config/mixins/_progress.scss +56 -0
- package/scss/config/mixins/_prose.scss +127 -0
- package/scss/config/mixins/_shadows.scss +8 -0
- package/scss/config/mixins/_sidebar.scss +95 -0
- package/scss/config/mixins/_sizing.scss +6 -0
- package/scss/config/mixins/_spacing.scss +19 -0
- package/scss/config/mixins/_stat-card.scss +68 -0
- package/scss/config/mixins/_status-badge.scss +83 -0
- package/scss/config/mixins/_stepper.scss +78 -0
- package/scss/config/mixins/_table.scss +215 -0
- package/scss/config/mixins/_tabs.scss +64 -0
- package/scss/config/mixins/_timeline.scss +69 -0
- package/scss/config/mixins/_toast.scss +148 -0
- package/scss/config/mixins/_tooltip.scss +111 -0
- package/scss/config/mixins/_transitions.scss +10 -0
- package/scss/config/mixins/_translations.scss +124 -0
- package/scss/config/mixins/_typography.scss +57 -0
- package/scss/config/mixins/_upload.scss +168 -0
- package/scss/ln-ashlar.scss +62 -0
- package/scss/tabler-icons.txt +5039 -0
- package/scss/utilities/_animations.scss +83 -0
- package/scss/utilities/_utilities.scss +49 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# ln-dropdown
|
|
2
|
+
|
|
3
|
+
> A menu-grade coordinator that adds click-outside, body teleportation, and automatic positioning on top of `ln-toggle`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Philosophy & The Dropdown Mindset
|
|
8
|
+
|
|
9
|
+
In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating a heavy component that bundles state, LIFO click stacks, teleportation contexts, and styles, `ln-dropdown` splits them cleanly:
|
|
10
|
+
|
|
11
|
+
1. **State Primitive (`ln-toggle`)**: Open/close state lives entirely on the inner `data-ln-toggle` attribute on the menu. `ln-dropdown` does not re-implement state; it is a thin behavior layer on top.
|
|
12
|
+
2. **Behavior & Positioning (JavaScript)**: The `ln-dropdown` coordinator handles click-outside detection, viewport resize closures (which makes absolute positioning unreliable), layout teleportation to `<body>` to escape parent `overflow: hidden` clips, and scroll position tracking.
|
|
13
|
+
3. **Visual Presentation (CSS)**: Visual layouts, background shadows, and borders are handled in Vanilla CSS. The library ships mixins like `@include dropdown` and `@include dropdown-menu` to style the wrapper and popup elements.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 2. Minimal Blueprint
|
|
18
|
+
|
|
19
|
+
Triggers and dropdown menus are paired by ID inside a wrapper container. Inactive menus are hidden via `ln-toggle` default rules.
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<!-- The Wrapper -->
|
|
23
|
+
<div data-ln-dropdown>
|
|
24
|
+
<!-- The Trigger -->
|
|
25
|
+
<button type="button" data-ln-toggle-for="options-menu">Options</button>
|
|
26
|
+
|
|
27
|
+
<!-- The Menu (State Primitive) -->
|
|
28
|
+
<ul id="options-menu" data-ln-toggle>
|
|
29
|
+
<li><a href="/profile">Profile</a></li>
|
|
30
|
+
<li><a href="/settings">Settings</a></li>
|
|
31
|
+
<li><hr></li>
|
|
32
|
+
<li><a href="/logout">Log out</a></li>
|
|
33
|
+
</ul>
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Key Anatomy Rules
|
|
38
|
+
- **The Wrapper (`data-ln-dropdown`)**: Creates the dropdown coordinator instance.
|
|
39
|
+
- **The Trigger (`data-ln-toggle-for="id"`)**: Standard `ln-toggle` button. ARIA attributes `aria-haspopup="menu"` and `aria-expanded` are synced automatically.
|
|
40
|
+
- **The Menu (`data-ln-toggle`)**: Standard `ln-toggle` element. Value `open` represents open; anything else is closed. Role `menu` is auto-injected.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 3. Declarative API & State Contract
|
|
45
|
+
|
|
46
|
+
There are no imperative JavaScript methods (like `open()` or `close()`) on the coordinator instance. **The HTML attribute is the sole contract.**
|
|
47
|
+
|
|
48
|
+
Outside clicks, window resizes, triggers, and custom scripts all change state by writing the active attribute on the inner menu element:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
const wrapper = document.querySelector('[data-ln-dropdown]');
|
|
52
|
+
const menu = wrapper.querySelector('[data-ln-toggle]');
|
|
53
|
+
|
|
54
|
+
// Open the menu (dropdown teleports and positions it automatically)
|
|
55
|
+
menu.setAttribute('data-ln-toggle', 'open');
|
|
56
|
+
|
|
57
|
+
// Close the menu
|
|
58
|
+
menu.setAttribute('data-ln-toggle', 'close');
|
|
59
|
+
|
|
60
|
+
// Cleanup the coordinator instance
|
|
61
|
+
wrapper.lnDropdown.destroy();
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Attributes
|
|
65
|
+
- `data-ln-dropdown`: Placed on the wrapper element to create the coordinator.
|
|
66
|
+
- `data-ln-toggle-for="id"`: Placed on trigger referencing the menu ID.
|
|
67
|
+
- `data-ln-toggle`: Placed on the menu element. Value `"open"` = open; anything else = closed.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 4. Transition Events
|
|
72
|
+
|
|
73
|
+
All events bubble. The dispatch target is the inner menu element (except `:destroyed` which dispatches on the wrapper).
|
|
74
|
+
|
|
75
|
+
| Event | Bubbles | Detail | Dispatched When |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| **`ln-dropdown:open`** | Yes | `{ target: menuElement }` | After teleportation and positioning are complete. |
|
|
78
|
+
| **`ln-dropdown:close`** | Yes | `{ target: menuElement }` | After menu is closed, teleported back, and outside listeners removed. |
|
|
79
|
+
| **`ln-dropdown:destroyed`** | Yes | `{ target: wrapperElement }` | Inside `destroy()`, after removing listeners. |
|
|
80
|
+
|
|
81
|
+
*Note*: Open/close state is managed by `ln-toggle`. Use `ln-toggle:before-open` / `ln-toggle:before-close` to cancel transitions.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// Example: React to dropdown open
|
|
85
|
+
document.addEventListener('ln-dropdown:open', (e) => {
|
|
86
|
+
console.log('Active dropdown:', e.detail.target.id);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 5. Behavior & Integration
|
|
93
|
+
|
|
94
|
+
- **Teleportation**: On open, the menu is moved to `<body>` so it escapes ancestor `overflow: hidden` clipping and parent stacking contexts. On close, it returns to its original position in the DOM.
|
|
95
|
+
- **Positioning**: The menu opens below the trigger, right-aligned to it. It automatically flips above if there is no vertical space below, and left-aligned if there is no horizontal space on the right.
|
|
96
|
+
- **Scroll & Resize Tracking**: Repositions automatically on every scroll to track the trigger. A window viewport resize closes the menu to prevent layout misalignments.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 6. Integration & Source Files
|
|
101
|
+
|
|
102
|
+
- **Unified Bundle**: Loaded automatically with the main bundle:
|
|
103
|
+
```html
|
|
104
|
+
<script src="dist/ln-ashlar.iife.js" defer></script>
|
|
105
|
+
```
|
|
106
|
+
- **Standalone IIFE**: For lightweight pages, load the standalone, self-registering IIFE version:
|
|
107
|
+
```html
|
|
108
|
+
<script src="js/ln-dropdown/ln-dropdown.js" defer></script>
|
|
109
|
+
```
|
|
110
|
+
- **Active Source (ESM)**: Development source is located at [js/ln-dropdown/src/ln-dropdown.js](file:///c:/laragon/www/ln-ashlar/js/ln-dropdown/src/ln-dropdown.js).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Related
|
|
115
|
+
- **[`ln-toggle`](../ln-toggle/README.md)** — Binary disclosure state primitive.
|
|
116
|
+
- **[`ln-popover`](../ln-popover/README.md)** — Viewport-aware click-triggered rich-content overlays.
|
|
117
|
+
- **Architecture deep-dive** — [`docs/js/dropdown.md`](../../docs/js/dropdown.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function v(t,o,n){t.dispatchEvent(new CustomEvent(o,{bubbles:!0,detail:n||{}}))}function k(t,o){if(!document.body){document.addEventListener("DOMContentLoaded",function(){k(t,o)}),console.warn("["+o+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function L(t,o,n,i){if(t.nodeType!==1)return;const l=o.indexOf("[")!==-1||o.indexOf(".")!==-1||o.indexOf("#")!==-1?o:"["+o+"]",r=Array.from(t.querySelectorAll(l));t.matches&&t.matches(l)&&r.push(t);for(const s of r)s[n]||(s[n]=new i(s))}function T(t,o,n,i,e={}){const l=e.extraAttributes||[],r=e.onAttributeChange||null,s=e.onInit||null;function g(a){const h=a||document.body;L(h,t,o,n),s&&s(h)}return k(function(){const a=new MutationObserver(function(f){for(let p=0;p<f.length;p++){const u=f[p];if(u.type==="childList")for(let m=0;m<u.addedNodes.length;m++){const _=u.addedNodes[m];_.nodeType===1&&(L(_,t,o,n),s&&s(_))}else u.type==="attributes"&&(r&&u.target[o]?r(u.target,u.attributeName):(L(u.target,t,o,n),s&&s(u.target)))}});let h=[];if(t.indexOf("[")!==-1){const f=/\[([\w-]+)/g;let p;for(;(p=f.exec(t))!==null;)h.push(p[1])}else h.push(t);a.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:h.concat(l)})},i),window[o]=g,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){g(document.body)}):g(document.body),g}const R={};function A(t,o){R[t]=o}function O(t){return R[t]||{ingress:o=>o,egress:o=>o}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=A,window.lnCore.getDataMapper=O);function x(t,o,n,i){const e=typeof i=="number"?i:4,l=window.innerWidth,r=window.innerHeight,s=o.width,g=o.height,a=n.split("-"),h=a[0],f=a[1]==="start"||a[1]==="end"?a[1]:"center",p={top:["top","bottom","right","left"],bottom:["bottom","top","right","left"],left:["left","right","top","bottom"],right:["right","left","top","bottom"]},u=p[h]||p.bottom;function m(d){return d==="top"||d==="bottom"?f==="start"?t.left:f==="end"?t.right-s:t.left+(t.width-s)/2:f==="start"?t.top:f==="end"?t.bottom-g:t.top+(t.height-g)/2}function _(d){let c,b,w=!0;return d==="top"?(c=t.top-e-g,b=m(d),c<0&&(w=!1)):d==="bottom"?(c=t.bottom+e,b=m(d),c+g>r&&(w=!1)):d==="left"?(c=m(d),b=t.left-e-s,b<0&&(w=!1)):(c=m(d),b=t.right+e,b+s>l&&(w=!1)),{top:c,left:b,side:d,fits:w}}let y=null;for(let d=0;d<u.length;d++){const c=_(u[d]);if(c.fits){y=c;break}}y||(y=_(u[0]));let E=y.top,C=y.left;return s>=l?C=0:(C<0&&(C=0),C+s>l&&(C=l-s)),g>=r?E=0:(E<0&&(E=0),E+g>r&&(E=r-g)),{top:E,left:C,placement:y.side}}function D(t){if(!t||t.parentNode===document.body)return function(){};const o=t.parentNode,n=document.createComment("ln-teleport");return o.insertBefore(n,t),document.body.appendChild(t),function(){n.parentNode&&(n.parentNode.insertBefore(t,n),n.parentNode.removeChild(n))}}function S(t){if(!t)return{width:0,height:0};const o=t.style,n=o.visibility,i=o.display,e=o.position;o.visibility="hidden",o.display="block",o.position="fixed";const l=t.offsetWidth,r=t.offsetHeight;return o.visibility=n,o.display=i,o.position=e,{width:l,height:r}}(function(){const t="data-ln-dropdown",o="lnDropdown";if(window[o]!==void 0)return;function n(i){if(this.dom=i,this.toggleEl=i.querySelector("[data-ln-toggle]"),this._teleportRestore=null,this._boundDocClick=null,this._docClickTimeout=null,this._boundScrollReposition=null,this._boundResizeClose=null,this.toggleEl&&(this.toggleEl.setAttribute("data-ln-dropdown-menu",""),this.toggleEl.setAttribute("role","menu")),this.triggerBtn=i.querySelector("[data-ln-toggle-for]"),this.triggerBtn&&(this.triggerBtn.setAttribute("aria-haspopup","menu"),this.triggerBtn.setAttribute("aria-expanded","false")),this.toggleEl)for(const l of this.toggleEl.children)l.setAttribute("role","menuitem");const e=this;return this._onToggleOpen=function(l){l.detail.target===e.toggleEl&&(e.triggerBtn&&e.triggerBtn.setAttribute("aria-expanded","true"),e._teleportRestore=D(e.toggleEl),e.toggleEl.style.position="fixed",e.toggleEl.style.right="auto",e._reposition(),e._addOutsideClickListener(),e._addScrollRepositionListener(),e._addResizeCloseListener(),v(i,"ln-dropdown:open",{target:l.detail.target}))},this._onToggleClose=function(l){l.detail.target===e.toggleEl&&(e.triggerBtn&&e.triggerBtn.setAttribute("aria-expanded","false"),e._removeOutsideClickListener(),e._removeScrollRepositionListener(),e._removeResizeCloseListener(),e.toggleEl.style.position="",e.toggleEl.style.top="",e.toggleEl.style.left="",e.toggleEl.style.right="",e.toggleEl.style.transform="",e.toggleEl.style.margin="",e._teleportRestore&&(e._teleportRestore(),e._teleportRestore=null),v(i,"ln-dropdown:close",{target:l.detail.target}))},this.toggleEl&&(this.toggleEl.addEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.addEventListener("ln-toggle:close",this._onToggleClose)),this}n.prototype._reposition=function(){if(!this.triggerBtn||!this.toggleEl)return;const i=this.triggerBtn.getBoundingClientRect(),e=S(this.toggleEl),l=parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--size-xs"))*16||4,r=x(i,e,"bottom-end",l);this.toggleEl.style.top=r.top+"px",this.toggleEl.style.left=r.left+"px"},n.prototype._addOutsideClickListener=function(){if(this._boundDocClick)return;const i=this;this._boundDocClick=function(e){i.dom.contains(e.target)||i.toggleEl&&i.toggleEl.contains(e.target)||i.toggleEl&&i.toggleEl.getAttribute("data-ln-toggle")==="open"&&i.toggleEl.setAttribute("data-ln-toggle","close")},i._docClickTimeout=setTimeout(function(){i._docClickTimeout=null,document.addEventListener("click",i._boundDocClick)},0)},n.prototype._removeOutsideClickListener=function(){this._docClickTimeout&&(clearTimeout(this._docClickTimeout),this._docClickTimeout=null),this._boundDocClick&&(document.removeEventListener("click",this._boundDocClick),this._boundDocClick=null)},n.prototype._addScrollRepositionListener=function(){const i=this;this._boundScrollReposition=function(){i._reposition()},window.addEventListener("scroll",this._boundScrollReposition,{passive:!0,capture:!0})},n.prototype._removeScrollRepositionListener=function(){this._boundScrollReposition&&(window.removeEventListener("scroll",this._boundScrollReposition,{capture:!0}),this._boundScrollReposition=null)},n.prototype._addResizeCloseListener=function(){const i=this;this._boundResizeClose=function(){i.toggleEl&&i.toggleEl.getAttribute("data-ln-toggle")==="open"&&i.toggleEl.setAttribute("data-ln-toggle","close")},window.addEventListener("resize",this._boundResizeClose)},n.prototype._removeResizeCloseListener=function(){this._boundResizeClose&&(window.removeEventListener("resize",this._boundResizeClose),this._boundResizeClose=null)},n.prototype.destroy=function(){this.dom[o]&&(this._removeOutsideClickListener(),this._removeScrollRepositionListener(),this._removeResizeCloseListener(),this._teleportRestore&&(this._teleportRestore(),this._teleportRestore=null),this.toggleEl&&(this.toggleEl.removeEventListener("ln-toggle:open",this._onToggleOpen),this.toggleEl.removeEventListener("ln-toggle:close",this._onToggleClose)),v(this.dom,"ln-dropdown:destroyed",{target:this.dom}),delete this.dom[o])},T(t,o,n,"ln-dropdown")})()})();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@use '../../scss/config/mixins' as *;
|
|
2
|
+
|
|
3
|
+
// ── JS state: dropdown open/close ──
|
|
4
|
+
// ln-toggle adds/removes .open class. display:none is default (no menu visible).
|
|
5
|
+
// Animation plays on open via @keyframes in component file.
|
|
6
|
+
[data-ln-dropdown-menu] {
|
|
7
|
+
display: none;
|
|
8
|
+
|
|
9
|
+
&.open {
|
|
10
|
+
display: block;
|
|
11
|
+
@include motion-safe {
|
|
12
|
+
animation: ln-dropdown-in var(--transition-fast);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { dispatch, computePlacement, teleportToBody, measureHidden, registerComponent } from '../../ln-core';
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const DOM_SELECTOR = 'data-ln-dropdown';
|
|
5
|
+
const DOM_ATTRIBUTE = 'lnDropdown';
|
|
6
|
+
|
|
7
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
8
|
+
|
|
9
|
+
// ─── Component ─────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function _component(dom) {
|
|
12
|
+
this.dom = dom;
|
|
13
|
+
this.toggleEl = dom.querySelector('[data-ln-toggle]');
|
|
14
|
+
this._teleportRestore = null;
|
|
15
|
+
this._boundDocClick = null;
|
|
16
|
+
this._docClickTimeout = null;
|
|
17
|
+
this._boundScrollReposition = null;
|
|
18
|
+
this._boundResizeClose = null;
|
|
19
|
+
|
|
20
|
+
if (this.toggleEl) {
|
|
21
|
+
this.toggleEl.setAttribute('data-ln-dropdown-menu', '');
|
|
22
|
+
this.toggleEl.setAttribute('role', 'menu');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ARIA on trigger button
|
|
26
|
+
this.triggerBtn = dom.querySelector('[data-ln-toggle-for]');
|
|
27
|
+
if (this.triggerBtn) {
|
|
28
|
+
this.triggerBtn.setAttribute('aria-haspopup', 'menu');
|
|
29
|
+
this.triggerBtn.setAttribute('aria-expanded', 'false');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// role="menuitem" on direct children of menu
|
|
33
|
+
if (this.toggleEl) {
|
|
34
|
+
for (const item of this.toggleEl.children) {
|
|
35
|
+
item.setAttribute('role', 'menuitem');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const self = this;
|
|
40
|
+
|
|
41
|
+
this._onToggleOpen = function (e) {
|
|
42
|
+
if (e.detail.target !== self.toggleEl) return;
|
|
43
|
+
if (self.triggerBtn) self.triggerBtn.setAttribute('aria-expanded', 'true');
|
|
44
|
+
self._teleportRestore = teleportToBody(self.toggleEl);
|
|
45
|
+
self.toggleEl.style.position = 'fixed';
|
|
46
|
+
self.toggleEl.style.right = 'auto';
|
|
47
|
+
self._reposition();
|
|
48
|
+
self._addOutsideClickListener();
|
|
49
|
+
self._addScrollRepositionListener();
|
|
50
|
+
self._addResizeCloseListener();
|
|
51
|
+
dispatch(dom, 'ln-dropdown:open', { target: e.detail.target });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this._onToggleClose = function (e) {
|
|
55
|
+
if (e.detail.target !== self.toggleEl) return;
|
|
56
|
+
if (self.triggerBtn) self.triggerBtn.setAttribute('aria-expanded', 'false');
|
|
57
|
+
self._removeOutsideClickListener();
|
|
58
|
+
self._removeScrollRepositionListener();
|
|
59
|
+
self._removeResizeCloseListener();
|
|
60
|
+
self.toggleEl.style.position = '';
|
|
61
|
+
self.toggleEl.style.top = '';
|
|
62
|
+
self.toggleEl.style.left = '';
|
|
63
|
+
self.toggleEl.style.right = '';
|
|
64
|
+
self.toggleEl.style.transform = '';
|
|
65
|
+
self.toggleEl.style.margin = '';
|
|
66
|
+
if (self._teleportRestore) { self._teleportRestore(); self._teleportRestore = null; }
|
|
67
|
+
dispatch(dom, 'ln-dropdown:close', { target: e.detail.target });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (this.toggleEl) {
|
|
71
|
+
this.toggleEl.addEventListener('ln-toggle:open', this._onToggleOpen);
|
|
72
|
+
this.toggleEl.addEventListener('ln-toggle:close', this._onToggleClose);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Positioning ───────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
_component.prototype._reposition = function () {
|
|
81
|
+
if (!this.triggerBtn || !this.toggleEl) return;
|
|
82
|
+
const rect = this.triggerBtn.getBoundingClientRect();
|
|
83
|
+
const size = measureHidden(this.toggleEl);
|
|
84
|
+
const gap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--size-xs')) * 16 || 4;
|
|
85
|
+
const p = computePlacement(rect, size, 'bottom-end', gap);
|
|
86
|
+
this.toggleEl.style.top = p.top + 'px';
|
|
87
|
+
this.toggleEl.style.left = p.left + 'px';
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ─── Outside click ─────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
_component.prototype._addOutsideClickListener = function () {
|
|
93
|
+
if (this._boundDocClick) return;
|
|
94
|
+
const self = this;
|
|
95
|
+
this._boundDocClick = function (e) {
|
|
96
|
+
if (self.dom.contains(e.target)) return;
|
|
97
|
+
if (self.toggleEl && self.toggleEl.contains(e.target)) return;
|
|
98
|
+
if (self.toggleEl && self.toggleEl.getAttribute('data-ln-toggle') === 'open') {
|
|
99
|
+
self.toggleEl.setAttribute('data-ln-toggle', 'close');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
self._docClickTimeout = setTimeout(function () {
|
|
103
|
+
self._docClickTimeout = null;
|
|
104
|
+
document.addEventListener('click', self._boundDocClick);
|
|
105
|
+
}, 0);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
_component.prototype._removeOutsideClickListener = function () {
|
|
109
|
+
if (this._docClickTimeout) {
|
|
110
|
+
clearTimeout(this._docClickTimeout);
|
|
111
|
+
this._docClickTimeout = null;
|
|
112
|
+
}
|
|
113
|
+
if (this._boundDocClick) {
|
|
114
|
+
document.removeEventListener('click', this._boundDocClick);
|
|
115
|
+
this._boundDocClick = null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ─── Scroll → reposition ──────────────────────────────────
|
|
120
|
+
|
|
121
|
+
_component.prototype._addScrollRepositionListener = function () {
|
|
122
|
+
const self = this;
|
|
123
|
+
this._boundScrollReposition = function () {
|
|
124
|
+
self._reposition();
|
|
125
|
+
};
|
|
126
|
+
window.addEventListener('scroll', this._boundScrollReposition, { passive: true, capture: true });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
_component.prototype._removeScrollRepositionListener = function () {
|
|
130
|
+
if (this._boundScrollReposition) {
|
|
131
|
+
window.removeEventListener('scroll', this._boundScrollReposition, { capture: true });
|
|
132
|
+
this._boundScrollReposition = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ─── Resize → close ───────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
_component.prototype._addResizeCloseListener = function () {
|
|
139
|
+
const self = this;
|
|
140
|
+
this._boundResizeClose = function () {
|
|
141
|
+
if (self.toggleEl && self.toggleEl.getAttribute('data-ln-toggle') === 'open') {
|
|
142
|
+
self.toggleEl.setAttribute('data-ln-toggle', 'close');
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
window.addEventListener('resize', this._boundResizeClose);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
_component.prototype._removeResizeCloseListener = function () {
|
|
149
|
+
if (this._boundResizeClose) {
|
|
150
|
+
window.removeEventListener('resize', this._boundResizeClose);
|
|
151
|
+
this._boundResizeClose = null;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ─── Destroy ───────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
_component.prototype.destroy = function () {
|
|
158
|
+
if (!this.dom[DOM_ATTRIBUTE]) return;
|
|
159
|
+
this._removeOutsideClickListener();
|
|
160
|
+
this._removeScrollRepositionListener();
|
|
161
|
+
this._removeResizeCloseListener();
|
|
162
|
+
if (this._teleportRestore) { this._teleportRestore(); this._teleportRestore = null; }
|
|
163
|
+
if (this.toggleEl) {
|
|
164
|
+
this.toggleEl.removeEventListener('ln-toggle:open', this._onToggleOpen);
|
|
165
|
+
this.toggleEl.removeEventListener('ln-toggle:close', this._onToggleClose);
|
|
166
|
+
}
|
|
167
|
+
dispatch(this.dom, 'ln-dropdown:destroyed', { target: this.dom });
|
|
168
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// ─── Init ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-dropdown');
|
|
174
|
+
})();
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# ln-external-links
|
|
2
|
+
|
|
3
|
+
Auto-decorates every cross-host `<a>` and `<area>` on the page with `target="_blank"`, merged `rel="noopener noreferrer"`, and a screen-reader hint span. Runs on page load and on every DOM mutation; no opt-in attribute, no init call, no API surface for consumers to wire.
|
|
4
|
+
|
|
5
|
+
For the host-comparison decision table, script-load lifecycle, and architecture rationale, see [`docs/js/external-links.md`](../../docs/js/external-links.md).
|
|
6
|
+
|
|
7
|
+
## Markup anatomy
|
|
8
|
+
|
|
9
|
+
There is no markup contract. Every `<a>` and `<area>` on the page
|
|
10
|
+
participates by default. The "before" / "after" view tells the whole
|
|
11
|
+
story:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<!-- Before (your authored HTML) -->
|
|
15
|
+
<a href="https://example.com">Read more</a>
|
|
16
|
+
|
|
17
|
+
<!-- After (what ln-external-links produces) -->
|
|
18
|
+
<a href="https://example.com"
|
|
19
|
+
target="_blank"
|
|
20
|
+
rel="noopener noreferrer"
|
|
21
|
+
data-ln-external-link="processed">
|
|
22
|
+
Read more
|
|
23
|
+
<span class="sr-only">(opens in new tab)</span>
|
|
24
|
+
</a>
|
|
25
|
+
|
|
26
|
+
<!-- Internal links — no change at all -->
|
|
27
|
+
<a href="/dashboard">Dashboard</a>
|
|
28
|
+
<a href="#section-2">Jump to section</a>
|
|
29
|
+
<a href="mailto:hello@livenetworks.mk">Email us</a>
|
|
30
|
+
<a href="tel:+38970000000">Call us</a>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Four edits happen on each external link, in this order:
|
|
34
|
+
|
|
35
|
+
1. `target="_blank"` — opens in a new tab.
|
|
36
|
+
2. `rel="noopener noreferrer"` — security + privacy.
|
|
37
|
+
3. A `<span class="sr-only">(opens in new tab)</span>` is appended
|
|
38
|
+
inside the anchor as the last child. Sighted users see no
|
|
39
|
+
change; assistive tech announces "Read more, opens in new tab"
|
|
40
|
+
when the link receives focus. WCAG 2.4.4 ("Link Purpose")
|
|
41
|
+
recommends this; the component does it for you.
|
|
42
|
+
4. `data-ln-external-link="processed"` — the idempotency marker.
|
|
43
|
+
Subsequent processing passes (initial scan, MutationObserver
|
|
44
|
+
re-fire, manual `lnExternalLinks.process()` call) early-return on
|
|
45
|
+
this attribute so the link is never double-decorated.
|
|
46
|
+
|
|
47
|
+
### What state lives where
|
|
48
|
+
|
|
49
|
+
| Concern | Lives on | Owned by |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Has this link been processed? | `data-ln-external-link="processed"` on the `<a>` / `<area>` | ln-external-links (writes once, reads on every pass) |
|
|
52
|
+
| Should it open in a new tab? | `target="_blank"` on the link | ln-external-links (writes) |
|
|
53
|
+
| Security / privacy posture | `rel="noopener noreferrer"` on the link | ln-external-links (writes — merges with prior `rel`) |
|
|
54
|
+
| Screen-reader hint | `<span class="sr-only">` appended inside the link | ln-external-links (creates and appends) |
|
|
55
|
+
| Click event delivery | `document.body` click delegate | ln-external-links (one listener for the whole page) |
|
|
56
|
+
| Decoration of new DOM | `MutationObserver` on `document.body`, `childList: true, subtree: true, attributes: true, attributeFilter: ['href']` | ln-external-links |
|
|
57
|
+
|
|
58
|
+
## States & visual feedback
|
|
59
|
+
|
|
60
|
+
There is no visual state. Decoration is invisible to sighted users
|
|
61
|
+
(target/rel are non-rendering attributes, the sr-only span is hidden
|
|
62
|
+
by the utility class). The "before/after" is purely the four mutations
|
|
63
|
+
listed above.
|
|
64
|
+
|
|
65
|
+
| Trigger | What JS does | What the user sees |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Page load with external links in initial DOM | On `DOMContentLoaded`, `_processLinks()` walks `document.body.querySelectorAll('a, area')` and decorates each external one | Nothing visual. Behavior change: clicks now open in new tab. |
|
|
68
|
+
| AJAX inserts a fragment with external links | MutationObserver fires; component decorates new `<a>` / `<area>` nodes (and any nested in inserted subtrees) | Same — invisible decoration. |
|
|
69
|
+
| Existing link's `href` changes from internal to external | MutationObserver attribute observer fires; `_processLink` runs on the link | Decoration appears on the next microtask — same flow as DOM insertion. |
|
|
70
|
+
| User clicks a processed external link | `document.body` click delegate fires `ln-external-links:clicked` on the link | Default browser navigation in a new tab. The component does NOT preventDefault — Ctrl/Cmd-click, middle-click, etc., behave normally. |
|
|
71
|
+
| User clicks an internal link | `closest('a, area')` resolves; `data-ln-external-link === 'processed'` is false; no event dispatched | Normal navigation. |
|
|
72
|
+
| Screen reader navigates to a processed external link | (no JS) | Hears the link text followed by "(opens in new tab)". |
|
|
73
|
+
|
|
74
|
+
There are no JS-driven classes, no `aria-*` toggles, no transition or
|
|
75
|
+
animation. The component sets attributes, appends one span, and emits
|
|
76
|
+
events. Everything else is the platform.
|
|
77
|
+
|
|
78
|
+
## Attributes
|
|
79
|
+
|
|
80
|
+
ln-external-links is markup-free in the consumer-author sense — there
|
|
81
|
+
is no attribute *you* add to opt in. The attributes that DO show up
|
|
82
|
+
are written by the component itself, with one read-only exception
|
|
83
|
+
that doubles as an opt-out hatch.
|
|
84
|
+
|
|
85
|
+
| Attribute | On | Direction | Description |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `data-ln-external-link="processed"` | `<a>` / `<area>` | written by component, read on each pass | Idempotency marker. Set after a link is decorated. The processing function early-returns on links that already carry it. **Pre-setting it manually** in your markup makes the component skip the link entirely — the unofficial opt-out path. |
|
|
88
|
+
| `target` | `<a>` / `<area>` | written | Set to `_blank` on every external link. **Overwrites** any pre-existing `target` value. |
|
|
89
|
+
| `rel` | `<a>` / `<area>` | written | Merged: `noopener` and `noreferrer` are added if not already present. Pre-existing tokens (`me`, `author`, `license`, `nofollow`, `ugc`, `sponsored`) survive on the link. |
|
|
90
|
+
|
|
91
|
+
There is no `data-ln-external-links` attribute. Decoration is
|
|
92
|
+
unconditional; the component does not look for a marker on the
|
|
93
|
+
element or on a parent.
|
|
94
|
+
|
|
95
|
+
## Events
|
|
96
|
+
|
|
97
|
+
Two events bubble; neither is cancelable. Both fire on the link
|
|
98
|
+
itself.
|
|
99
|
+
|
|
100
|
+
| Event | Bubbles | Cancelable | `detail` | Dispatched when | Common consumer |
|
|
101
|
+
|---|---|---|---|---|---|
|
|
102
|
+
| `ln-external-links:processed` | yes | no | `{ link: HTMLAnchorElement, href: string }` | A link finishes decoration (after `target`, `rel`, sr-only span, and the marker attribute are all written) | Auditing / debug logging; verifying decoration coverage |
|
|
103
|
+
| `ln-external-links:clicked` | yes | no | `{ link, href: string, text: string }` | The user clicks anywhere inside a processed external link. `text` is `link.textContent || link.title || ''` — the visible label, falling back to the title attribute, falling back to empty string | Analytics / outbound-link tracking |
|
|
104
|
+
|
|
105
|
+
The `:clicked` event is a **notification, not a hook**. The component does not call `preventDefault()` and does not honor a consumer's `preventDefault()` on the CustomEvent. To intercept navigation, attach a click listener on the link or a parent in the **capture phase**, and `preventDefault()` the platform `click` event. The demo page shows a working interstitial.
|
|
106
|
+
|
|
107
|
+
## API (global service)
|
|
108
|
+
|
|
109
|
+
ln-external-links exposes a single function on `window`:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
window.lnExternalLinks.process(container)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Argument | Type | Default | Description |
|
|
116
|
+
|---|---|---|---|
|
|
117
|
+
| `container` | `HTMLElement` | `document.body` | Subtree to scan for `a, area` elements. Each match runs through the same `_processLink` path used by the initial scan and the MutationObserver. Already-processed links no-op. |
|
|
118
|
+
|
|
119
|
+
When to call it manually:
|
|
120
|
+
|
|
121
|
+
- **You injected markup with `innerHTML` and want decoration to
|
|
122
|
+
apply *synchronously*** before your next line runs. The
|
|
123
|
+
MutationObserver runs asynchronously (microtask), so a setup like
|
|
124
|
+
`el.innerHTML = '<a href="https://...">';
|
|
125
|
+
el.querySelector('a').target` reads the OLD `target` value because
|
|
126
|
+
the observer has not yet processed the addition. `lnExternalLinks.process(el)`
|
|
127
|
+
forces it through immediately.
|
|
128
|
+
- **You changed an existing external link's `href` to a *different*
|
|
129
|
+
external URL.** The observer's attribute filter on `href` re-fires
|
|
130
|
+
`_processLink`, but the marker is still set so the link short-circuits
|
|
131
|
+
unchanged. If you need a full re-decoration (e.g. you mutated the host
|
|
132
|
+
to a different one and want to re-emit `:processed`), remove the
|
|
133
|
+
marker first: `el.removeAttribute('data-ln-external-link')` then call
|
|
134
|
+
`process()`. The internal → external case does NOT need this — the
|
|
135
|
+
marker is absent, so the observer-driven re-process decorates
|
|
136
|
+
automatically.
|
|
137
|
+
|
|
138
|
+
## Examples
|
|
139
|
+
|
|
140
|
+
### Minimal — no consumer code at all
|
|
141
|
+
|
|
142
|
+
```html
|
|
143
|
+
<a href="https://example.com">Visit example.com</a>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
That's it. Page load decorates the link. New tab opens on click. Screen
|
|
147
|
+
readers get the hint. Zero JavaScript on the consumer side.
|
|
148
|
+
|
|
149
|
+
### Tracking external link clicks (analytics)
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
document.addEventListener('ln-external-links:clicked', function (e) {
|
|
153
|
+
const href = e.detail.href;
|
|
154
|
+
const label = e.detail.text;
|
|
155
|
+
|
|
156
|
+
// Send to your analytics tool of choice.
|
|
157
|
+
if (typeof gtag === 'function') {
|
|
158
|
+
gtag('event', 'click', {
|
|
159
|
+
event_category: 'outbound',
|
|
160
|
+
event_label: href,
|
|
161
|
+
transport_type: 'beacon'
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`transport_type: 'beacon'` is what makes the analytics call survive
|
|
168
|
+
the immediate `target="_blank"` navigation that follows.
|
|
169
|
+
|
|
170
|
+
### Decorating dynamically inserted markup
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
const container = document.getElementById('content');
|
|
174
|
+
container.innerHTML = `
|
|
175
|
+
<p>Read the <a href="https://github.com/livenetworks">source on GitHub</a>.</p>
|
|
176
|
+
<p>Or visit <a href="https://example.com">example.com</a>.</p>
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
// Option A: do nothing. The MutationObserver picks it up on the next
|
|
180
|
+
// microtask. By the time any user can click, the links are decorated.
|
|
181
|
+
|
|
182
|
+
// Option B: force synchronous decoration. Useful if your next line of
|
|
183
|
+
// JS reads link.target or link.rel and needs the new value immediately.
|
|
184
|
+
window.lnExternalLinks.process(container);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The two paths are functionally equivalent for end users. Option B
|
|
188
|
+
exists for the rare case where a downstream snippet reads decoration
|
|
189
|
+
state inline.
|
|
190
|
+
|
|
191
|
+
### Confirm-before-leaving interstitial
|
|
192
|
+
|
|
193
|
+
The library does NOT ship an interstitial. The pattern composes `ln-modal` with a capture-phase click listener that intercepts navigation BEFORE `ln-external-links`' bubble-phase delegate dispatches `:clicked`. See `demo/admin/external-links.html` for the full working implementation. Three details that matter when wiring your own:
|
|
194
|
+
|
|
195
|
+
1. **Capture phase (`true` as the third arg).** ln-external-links' click delegate runs in the bubble phase. To intercept BEFORE it dispatches `:clicked`, listen in capture.
|
|
196
|
+
2. **Modifier keys honored.** `Ctrl`-click / `Cmd`-click / `Shift`-click / middle-click should keep their browser semantics. The early-return for those preserves user intent.
|
|
197
|
+
3. **`window.open` with the same `'noopener,noreferrer'` features.** Once you preventDefault, `target="_blank"` no longer applies — open the URL manually with the same security flags the link carried.
|
|
198
|
+
|
|
199
|
+
### Whitelisting an external link to NOT be decorated
|
|
200
|
+
|
|
201
|
+
Pre-set the marker in your markup:
|
|
202
|
+
|
|
203
|
+
```html
|
|
204
|
+
<!-- This OAuth callback link must stay in the same tab.
|
|
205
|
+
Pre-marking it as 'processed' makes ln-external-links skip it. -->
|
|
206
|
+
<a href="https://oauth.example.com/return"
|
|
207
|
+
data-ln-external-link="processed">
|
|
208
|
+
Continue with Example
|
|
209
|
+
</a>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The component's `_processLink` early-returns when the marker is
|
|
213
|
+
already set. The link will NOT receive `target="_blank"` or
|
|
214
|
+
`rel="noopener noreferrer"`, and it will NOT trigger `:clicked`
|
|
215
|
+
events (they are gated on the same marker — line 45). Document this
|
|
216
|
+
hatch in your project; it works only because of how the idempotency
|
|
217
|
+
guard is implemented, not because the component declares "skip" as a
|
|
218
|
+
feature.
|
|
219
|
+
|
|
220
|
+
### Preserving a meaningful `rel` value (microformats)
|
|
221
|
+
|
|
222
|
+
The component **merges** `noopener` and `noreferrer` into any pre-existing
|
|
223
|
+
`rel` token list. Authoring `rel="me"` on a personal-website link in your
|
|
224
|
+
bio is enough — no pre-marking required. After decoration, the link
|
|
225
|
+
carries `rel="me noopener noreferrer"` and the auto sr-only hint span:
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
<!-- Before (your authored HTML) -->
|
|
229
|
+
<a href="https://livenetworks.mk" rel="me">Live Networks</a>
|
|
230
|
+
|
|
231
|
+
<!-- After (what ln-external-links produces) -->
|
|
232
|
+
<a href="https://livenetworks.mk"
|
|
233
|
+
rel="me noopener noreferrer"
|
|
234
|
+
target="_blank"
|
|
235
|
+
data-ln-external-link="processed">
|
|
236
|
+
Live Networks
|
|
237
|
+
<span class="sr-only">(opens in new tab)</span>
|
|
238
|
+
</a>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Common mistakes
|
|
242
|
+
|
|
243
|
+
### Mistake 1 — Reading `link.textContent` for analytics and getting the sr-only hint string
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
document.addEventListener('ln-external-links:clicked', function (e) {
|
|
247
|
+
const label = e.detail.text;
|
|
248
|
+
// label === "Read more (opens in new tab)" — includes the hint!
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`detail.text` is `link.textContent || link.title || ''`, evaluated at
|
|
253
|
+
click time *after* the hint span has been appended.
|
|
254
|
+
`link.textContent` returns the concatenated text of all descendants,
|
|
255
|
+
including the sr-only span. If your analytics dashboard collects
|
|
256
|
+
`event_label`, you'll see the hint suffix on every entry.
|
|
257
|
+
|
|
258
|
+
Two workarounds:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
// Option A: strip the hint suffix.
|
|
262
|
+
const cleanLabel = label.replace(/\s*\(opens in new tab\)\s*$/, '');
|
|
263
|
+
|
|
264
|
+
// Option B: read the original label before the hint span was appended.
|
|
265
|
+
// The hint is always the LAST child of the link.
|
|
266
|
+
const link = e.detail.link;
|
|
267
|
+
const labelOnly = Array.prototype.filter.call(link.childNodes, function (n) {
|
|
268
|
+
return !(n.nodeType === 1 && n.classList.contains('sr-only'));
|
|
269
|
+
}).map(function (n) { return n.textContent; }).join('').trim();
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Mistake 2 — Calling `preventDefault()` on `:clicked` and expecting the navigation to stop
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
// WRONG — this does nothing.
|
|
276
|
+
document.addEventListener('ln-external-links:clicked', function (e) {
|
|
277
|
+
if (someCondition) e.preventDefault();
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`:clicked` is a CustomEvent, not the platform `click`. It fires
|
|
282
|
+
*after* the click delegate's `closest('a, area')` resolution but
|
|
283
|
+
within the same handler — the platform's default action (navigation)
|
|
284
|
+
is governed by the original `click`, not by your CustomEvent's
|
|
285
|
+
`defaultPrevented`. ln-external-links does not call `preventDefault()`
|
|
286
|
+
on the click and does not check `defaultPrevented` on the event it
|
|
287
|
+
emits.
|
|
288
|
+
|
|
289
|
+
To intercept the navigation, attach a click listener on the link
|
|
290
|
+
or on a parent in the **capture phase**, and `preventDefault()` the
|
|
291
|
+
real `click` event. See the "Confirm-before-leaving interstitial"
|
|
292
|
+
example above.
|
|
293
|
+
|
|
294
|
+
## Loading & Development
|
|
295
|
+
|
|
296
|
+
### 1. In-Bundle (Standard Integration)
|
|
297
|
+
|
|
298
|
+
To load `ln-external-links` as part of the unified `ln-ashlar` library, include the main bundle in your HTML document:
|
|
299
|
+
|
|
300
|
+
```html
|
|
301
|
+
<script src="dist/ln-ashlar.iife.js" defer></script>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
When loaded via the main bundle, the component automatically initializes and registers itself, listening for DOM mutations to scan and attach to matching elements.
|
|
305
|
+
|
|
306
|
+
### 2. Standalone (Zero-Dependency IIFE)
|
|
307
|
+
|
|
308
|
+
If you only need the external links auto-decoration functionality without the rest of the `ln-ashlar` suite, you can load the compiled standalone IIFE version:
|
|
309
|
+
|
|
310
|
+
```html
|
|
311
|
+
<script src="js/ln-external-links/ln-external-links.js" defer></script>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
This self-contained script executes immediately and sets up the necessary hooks and global initializer `window.lnExternalLinks` without requiring any external dependencies.
|
|
315
|
+
|
|
316
|
+
### 3. Source & Development Path
|
|
317
|
+
|
|
318
|
+
* **Active Development Source:** [js/ln-external-links/src/ln-external-links.js](file:///c:/laragon/www/ln-ashlar/js/ln-external-links/src/ln-external-links.js) — The authoring source code containing the ES module implementation. Any new features, bug fixes, or behavioral changes must be written here.
|
|
319
|
+
* **Compiled Standalone:** [js/ln-external-links/ln-external-links.js](file:///c:/laragon/www/ln-ashlar/js/ln-external-links/ln-external-links.js) — The compiled distribution file generated from the source code during the build process. Do not edit this file directly.
|
|
320
|
+
|
|
321
|
+
## Related
|
|
322
|
+
|
|
323
|
+
- **`@mixin ln-icon`** is unused here — there's no icon decoration on
|
|
324
|
+
external links by default. If you want a "↗" indicator, add it in
|
|
325
|
+
project SCSS via `a[data-ln-external-link="processed"]::after`.
|
|
326
|
+
- **`.sr-only`** (`scss/utilities/_utilities.scss`) — the
|
|
327
|
+
visually-hidden utility used by the appended hint span. If your
|
|
328
|
+
project does not include ln-ashlar utilities, supply your own
|
|
329
|
+
`.sr-only` definition or the hint will be visible.
|
|
330
|
+
- **[`ln-modal`](../ln-modal/README.md)** — the demo page composes
|
|
331
|
+
`ln-modal` with this component to show a "leaving the site"
|
|
332
|
+
interstitial. The composition is project code, not library code;
|
|
333
|
+
see the "Confirm-before-leaving interstitial" example.
|
|
334
|
+
- **Architecture deep-dive:** [`docs/js/external-links.md`](../../docs/js/external-links.md)
|
|
335
|
+
for the global-service pattern, the `_isExternalLink` decision
|
|
336
|
+
table, and the script-load lifecycle.
|
|
337
|
+
- **Cross-component principles:** [`docs/architecture/data-flow.md`](../../docs/architecture/data-flow.md)
|
|
338
|
+
— ln-external-links sits OUTSIDE the four-layer data flow. It is
|
|
339
|
+
not Data, Submit, Render, or Validate. It is a global decorator
|
|
340
|
+
that mutates DOM in response to insertion events; the data flow
|
|
341
|
+
story does not apply.
|