@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,137 @@
|
|
|
1
|
+
# ln-tabs
|
|
2
|
+
|
|
3
|
+
> N-way exclusive panel selection on a single container, managed reactively via the DOM.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Philosophy & The Tabs Mindset
|
|
8
|
+
|
|
9
|
+
In `ln-ashlar`, the core design principle is **orthogonality**. Rather than creating heavy components that mix state, visual presentation, and layout, `ln-tabs` separates them into isolated concerns:
|
|
10
|
+
|
|
11
|
+
1. **State & ARIA (JavaScript)**: The `ln-tabs` component (145 lines) only manages the active tab key in the DOM, maps namespace URL hashes, handles optional `localStorage` persistence, and synchronizes ARIA accessibility. It possesses zero visual styles.
|
|
12
|
+
2. **Visual Presentation (CSS)**: Visual layouts, borders, alignments, and active indicator designs are handled in Vanilla CSS. The library ships mixins like `@mixin tabs-nav`, `@mixin tabs-tab`, and `@mixin tabs-panel` to handle this elegantly.
|
|
13
|
+
3. **Decoupled Binding (HTML)**: Tab triggers and panels are paired purely by string keys (`data-ln-tab="key"` and `data-ln-panel="key"`), decoupled from their relative DOM positions.
|
|
14
|
+
|
|
15
|
+
### Why not built on `ln-toggle`?
|
|
16
|
+
While similar on the surface, their contracts diverge. `ln-toggle` is a binary disclosure primitive (using `aria-expanded`). `ln-tabs` is an N-way exclusive tablist (using `aria-selected` and `aria-hidden`) that supports advanced, namespace-scoped URL deep-linking out of the box.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. Minimal Blueprint
|
|
21
|
+
|
|
22
|
+
Triggers and panels are bound via matching keys inside a wrapper. Inactive panels must carry `class="hidden"` to prevent a layout flash before initialization.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">
|
|
26
|
+
<!-- Tab list (triggers) -->
|
|
27
|
+
<nav>
|
|
28
|
+
<button type="button" data-ln-tab="info">Information</button>
|
|
29
|
+
<button type="button" data-ln-tab="settings">Settings</button>
|
|
30
|
+
</nav>
|
|
31
|
+
|
|
32
|
+
<!-- Tab panels -->
|
|
33
|
+
<section data-ln-panel="info">
|
|
34
|
+
<p>This is the info panel.</p>
|
|
35
|
+
</section>
|
|
36
|
+
<section data-ln-panel="settings" class="hidden">
|
|
37
|
+
<p>This is the settings panel.</p>
|
|
38
|
+
</section>
|
|
39
|
+
</section>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Key Anatomy Rules
|
|
43
|
+
- **The Wrapper (`data-ln-tabs`)**: Creates the tabs root instance.
|
|
44
|
+
- **The Trigger (`data-ln-tab="key"`)**: Marks the element as a click target. Must be a `<button>` (with `type="button"` inside forms) or an `<a>` anchor.
|
|
45
|
+
- **The Panel (`data-ln-panel="key"`)**: Matches the trigger by key. Inactive panels must carry `class="hidden"`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 3. The Declarative API & State Contract
|
|
50
|
+
|
|
51
|
+
There are no imperative JavaScript methods (like `activate()` or `open()`) on the component instance. **The HTML attribute is the sole contract.**
|
|
52
|
+
|
|
53
|
+
Clicks, URL hash changes, localStorage restorations, and external scripts all change state by writing the active attribute on the wrapper:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const tabs = document.getElementById('user-tabs');
|
|
57
|
+
|
|
58
|
+
// Canonical write — switches the active tab
|
|
59
|
+
tabs.setAttribute('data-ln-tabs-active', 'settings');
|
|
60
|
+
|
|
61
|
+
// Read-only state query
|
|
62
|
+
tabs.getAttribute('data-ln-tabs-active'); // Returns currently active key
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Attributes
|
|
66
|
+
- `data-ln-tabs`: Placed on the wrapper to create the instance.
|
|
67
|
+
- `data-ln-tabs-active`: Currently active key (written by the component, watched by the observer).
|
|
68
|
+
- `data-ln-tabs-default="key"`: Default key selected on load. Falls back to the first tab trigger if omitted.
|
|
69
|
+
- `data-ln-tabs-focus="false"`: Opt out of auto-focusing the first focusable element inside the active panel. Default: enabled.
|
|
70
|
+
- `data-ln-tabs-key="name"`: Hash namespace. Falls back to wrapper `id` if omitted.
|
|
71
|
+
- `id="name"`: Enables hash sync. The wrapper `id` acts as the URL namespace key.
|
|
72
|
+
- `data-ln-persist`: Saves the active tab key in `localStorage` (effective only when hash sync is OFF).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 4. Transition Events
|
|
77
|
+
|
|
78
|
+
All events bubble. The dispatch target is the wrapper element.
|
|
79
|
+
|
|
80
|
+
| Event | Cancelable | `detail` | Dispatched When |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **`ln-tabs:change`** | No | `{ key, tab, panel }` | After the active panel is swapped, ARIA synced, focus moved (if enabled), and localStorage updated. |
|
|
83
|
+
| **`ln-tabs:destroyed`** | No | `{ target }` | Inside `destroy()`, after removing click and hashchange listeners. |
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
// Example: Listen for tab changes
|
|
87
|
+
document.addEventListener('ln-tabs:change', (e) => {
|
|
88
|
+
console.log(`Active tab in ${e.target.id} changed to: ${e.detail.key}`);
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 5. Integration Patterns
|
|
95
|
+
|
|
96
|
+
### A. Hash-Deep-Linkable Tabs (URL-as-State)
|
|
97
|
+
Add an `id` to the wrapper. Clicking tabs automatically writes to the URL hash (e.g. `#user-tabs:settings`). Sharing, bookmarking, or using back/forward buttons restores the active tab on load.
|
|
98
|
+
```html
|
|
99
|
+
<section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">...</section>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### B. Anchor Triggers (Deep Links)
|
|
103
|
+
Use `<a>` triggers with matching `href` format and boolean `data-ln-tab` attributes. Right-click copy link and middle-click "open in new tab" work out of the box.
|
|
104
|
+
```html
|
|
105
|
+
<a href="#user-tabs:info" data-ln-tab>Information</a>
|
|
106
|
+
<a href="#user-tabs:settings" data-ln-tab>Settings</a>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### C. Multiple Independent Tabsets
|
|
110
|
+
Multiple independent tabsets on the same page will coexist cleanly in the URL hash, namespaced by their respective wrapper `id`s (e.g., `#user-tabs:settings&project-tabs:members`).
|
|
111
|
+
|
|
112
|
+
### D. Persistent Tabs (Without URL Hash)
|
|
113
|
+
Omit the wrapper `id` and add `data-ln-persist="key"` to remember the active tab in `localStorage` without changing the URL hash.
|
|
114
|
+
```html
|
|
115
|
+
<section data-ln-tabs data-ln-persist="settings-tabs" data-ln-tabs-default="general">...</section>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 6. Integration & Source Files
|
|
121
|
+
|
|
122
|
+
- **Unified Bundle**: Loaded automatically with the main bundle:
|
|
123
|
+
```html
|
|
124
|
+
<script src="dist/ln-ashlar.iife.js" defer></script>
|
|
125
|
+
```
|
|
126
|
+
- **Standalone IIFE**: For lightweight pages, load the standalone, self-registering IIFE version:
|
|
127
|
+
```html
|
|
128
|
+
<script src="js/ln-tabs/ln-tabs.js" defer></script>
|
|
129
|
+
```
|
|
130
|
+
- **Active Source (ESM)**: Development source is located at [js/ln-tabs/src/ln-tabs.js](file:///c:/laragon/www/ln-ashlar/js/ln-tabs/src/ln-tabs.js).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Related
|
|
135
|
+
- **[`ln-toggle`](../ln-toggle/README.md)** — Binary disclosure state primitive.
|
|
136
|
+
- **[`ln-accordion`](../ln-accordion/README.md)** — Single-open coordinator built on `ln-toggle`.
|
|
137
|
+
- **Architecture deep-dive** — [`docs/js/tabs.md`](../../docs/js/tabs.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function g(i,n,r){i.dispatchEvent(new CustomEvent(n,{bubbles:!0,detail:r||{}}))}function y(i,n){if(!document.body){document.addEventListener("DOMContentLoaded",function(){y(i,n)}),console.warn("["+n+'] Script loaded before <body> — add "defer" to your <script> tag');return}i()}function m(i,n,r,l){if(i.nodeType!==1)return;const p=n.indexOf("[")!==-1||n.indexOf(".")!==-1||n.indexOf("#")!==-1?n:"["+n+"]",e=Array.from(i.querySelectorAll(p));i.matches&&i.matches(p)&&e.push(i);for(const t of e)t[r]||(t[r]=new l(t))}function v(i,n,r,l,h={}){const p=h.extraAttributes||[],e=h.onAttributeChange||null,t=h.onInit||null;function s(a){const o=a||document.body;m(o,i,n,r),t&&t(o)}return y(function(){const a=new MutationObserver(function(u){for(let d=0;d<u.length;d++){const c=u[d];if(c.type==="childList")for(let f=0;f<c.addedNodes.length;f++){const b=c.addedNodes[f];b.nodeType===1&&(m(b,i,n,r),t&&t(b))}else c.type==="attributes"&&(e&&c.target[n]?e(c.target,c.attributeName):(m(c.target,i,n,r),t&&t(c.target)))}});let o=[];if(i.indexOf("[")!==-1){const u=/\[([\w-]+)/g;let d;for(;(d=u.exec(i))!==null;)o.push(d[1])}else o.push(i);a.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:o.concat(p)})},l),window[n]=s,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){s(document.body)}):s(document.body),s}const w={};function C(i,n){w[i]=n}function E(i){return w[i]||{ingress:n=>n,egress:n=>n}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=C,window.lnCore.getDataMapper=E);const L="ln:";function T(){return location.pathname.replace(/\/+$/,"").toLowerCase()||"/"}function A(i,n){const r=n.getAttribute("data-ln-persist"),l=r!==null&&r!==""?r:n.id;return l?L+i+":"+T()+":"+l:(console.warn('[ln-persist] Element requires id or data-ln-persist="key"',n),null)}function _(i,n){const r=A(i,n);if(!r)return null;try{const l=localStorage.getItem(r);return l!==null?JSON.parse(l):null}catch{return null}}function x(i,n,r){const l=A(i,n);if(l)try{localStorage.setItem(l,JSON.stringify(r))}catch{}}(function(){const i="data-ln-tabs",n="lnTabs";if(window[n]!==void 0&&window[n]!==null)return;function r(){const e=(location.hash||"").replace("#",""),t={};if(!e)return t;for(const s of e.split("&")){const a=s.indexOf(":");a>0&&(t[s.slice(0,a)]=s.slice(a+1))}return t}function l(e,t){const s=(e.getAttribute("data-ln-tab")||"").toLowerCase().trim();if(s)return s;if(e.tagName!=="A")return"";const a=e.getAttribute("href")||"";if(!a.startsWith("#"))return"";const o=a.slice(1);if(!o)return"";const u=o.split("&");if(t)for(const f of u){const b=f.indexOf(":");if(b>0&&f.slice(0,b).toLowerCase().trim()===t)return f.slice(b+1).toLowerCase().trim()}const d=u[u.length-1]||"",c=d.indexOf(":");return(c>0?d.slice(c+1):d).toLowerCase().trim()}function h(e){return this.dom=e,p.call(this),this}function p(){this.tabs=Array.from(this.dom.querySelectorAll("[data-ln-tab]")),this.panels=Array.from(this.dom.querySelectorAll("[data-ln-panel]")),this.nsKey=(this.dom.getAttribute("data-ln-tabs-key")||this.dom.id||"").toLowerCase().trim(),this.hashEnabled=!!this.nsKey,this.mapTabs={},this.mapPanels={};for(const t of this.tabs){const s=l(t,this.nsKey);s?this.mapTabs[s]=t:console.warn('[ln-tabs] Trigger has no resolvable key — needs `data-ln-tab="key"` or `<a href="#…">`.',t)}for(const t of this.panels){const s=(t.getAttribute("data-ln-panel")||"").toLowerCase().trim();s&&(this.mapPanels[s]=t)}this.defaultKey=(this.dom.getAttribute("data-ln-tabs-default")||"").toLowerCase().trim()||Object.keys(this.mapTabs)[0]||"",this.autoFocus=(this.dom.getAttribute("data-ln-tabs-focus")||"true").toLowerCase()!=="false";const e=this;this._clickHandlers=[];for(const t of this.tabs){if(t[n+"Trigger"])continue;const s=function(a){if(a.ctrlKey||a.metaKey||a.button===1)return;const o=l(t,e.nsKey);if(o)if(t.tagName==="A"&&a.preventDefault(),e.hashEnabled){const u=r();u[e.nsKey]=o;const d=Object.keys(u).map(function(c){return c+":"+u[c]}).join("&");location.hash==="#"+d?e.dom.setAttribute("data-ln-tabs-active",o):location.hash=d}else e.dom.setAttribute("data-ln-tabs-active",o)};t.addEventListener("click",s),t[n+"Trigger"]=s,e._clickHandlers.push({el:t,handler:s})}if(this._hashHandler=function(){if(!e.hashEnabled)return;const t=r();e.dom.setAttribute("data-ln-tabs-active",e.nsKey in t?t[e.nsKey]:e.defaultKey)},this.hashEnabled)window.addEventListener("hashchange",this._hashHandler),this._hashHandler();else{let t=this.defaultKey;if(this.dom.hasAttribute("data-ln-persist")&&!this.hashEnabled){const s=_("tabs",this.dom);s!==null&&s in this.mapPanels&&(t=s)}this.dom.setAttribute("data-ln-tabs-active",t)}}h.prototype._applyActive=function(e){var t;(!e||!(e in this.mapPanels))&&(e=this.defaultKey);for(const s in this.mapTabs){const a=this.mapTabs[s];s===e?(a.setAttribute("data-active",""),a.setAttribute("aria-selected","true")):(a.removeAttribute("data-active"),a.setAttribute("aria-selected","false"))}for(const s in this.mapPanels){const a=this.mapPanels[s],o=s===e;a.classList.toggle("hidden",!o),a.setAttribute("aria-hidden",o?"false":"true")}if(this.autoFocus){const s=(t=this.mapPanels[e])==null?void 0:t.querySelector('input,button,select,textarea,[tabindex]:not([tabindex="-1"])');s&&setTimeout(()=>s.focus({preventScroll:!0}),0)}g(this.dom,"ln-tabs:change",{key:e,tab:this.mapTabs[e],panel:this.mapPanels[e]}),this.dom.hasAttribute("data-ln-persist")&&!this.hashEnabled&&x("tabs",this.dom,e)},h.prototype.destroy=function(){if(this.dom[n]){for(const{el:e,handler:t}of this._clickHandlers)e.removeEventListener("click",t),delete e[n+"Trigger"];this.hashEnabled&&window.removeEventListener("hashchange",this._hashHandler),g(this.dom,"ln-tabs:destroyed",{target:this.dom}),delete this.dom[n]}},v(i,n,h,"ln-tabs",{extraAttributes:["data-ln-tabs-active"],onAttributeChange:function(e){const t=e.getAttribute("data-ln-tabs-active");e[n]._applyActive(t)}})})()})();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* Live Networks - lnTabs (hash-aware tabs — supports <button> and <a href="#nsKey:key"> triggers) */
|
|
2
|
+
import { registerComponent, dispatch } from '../../ln-core';
|
|
3
|
+
import { persistGet, persistSet } from '../../ln-core';
|
|
4
|
+
|
|
5
|
+
(function () {
|
|
6
|
+
const DOM_SELECTOR = "data-ln-tabs";
|
|
7
|
+
const DOM_ATTRIBUTE = "lnTabs";
|
|
8
|
+
|
|
9
|
+
if (window[DOM_ATTRIBUTE] !== undefined && window[DOM_ATTRIBUTE] !== null) return;
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
function _parseHash() {
|
|
13
|
+
const h = (location.hash || "").replace("#", "");
|
|
14
|
+
const map = {};
|
|
15
|
+
if (!h) return map;
|
|
16
|
+
for (const part of h.split("&")) {
|
|
17
|
+
const sep = part.indexOf(":");
|
|
18
|
+
if (sep > 0) map[part.slice(0, sep)] = part.slice(sep + 1);
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _keyFromTrigger(t, nsKey) {
|
|
24
|
+
const explicit = (t.getAttribute("data-ln-tab") || "").toLowerCase().trim();
|
|
25
|
+
if (explicit) return explicit;
|
|
26
|
+
if (t.tagName !== "A") return "";
|
|
27
|
+
const href = t.getAttribute("href") || "";
|
|
28
|
+
if (!href.startsWith("#")) return "";
|
|
29
|
+
const raw = href.slice(1);
|
|
30
|
+
if (!raw) return "";
|
|
31
|
+
const fragments = raw.split("&");
|
|
32
|
+
if (nsKey) {
|
|
33
|
+
for (const frag of fragments) {
|
|
34
|
+
const sep = frag.indexOf(":");
|
|
35
|
+
if (sep > 0 && frag.slice(0, sep).toLowerCase().trim() === nsKey) {
|
|
36
|
+
return frag.slice(sep + 1).toLowerCase().trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const last = fragments[fragments.length - 1] || "";
|
|
41
|
+
const sep = last.indexOf(":");
|
|
42
|
+
return (sep > 0 ? last.slice(sep + 1) : last).toLowerCase().trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _component(dom) { this.dom = dom; _init.call(this); return this; }
|
|
46
|
+
|
|
47
|
+
function _init() {
|
|
48
|
+
this.tabs = Array.from(this.dom.querySelectorAll("[data-ln-tab]"));
|
|
49
|
+
this.panels = Array.from(this.dom.querySelectorAll("[data-ln-panel]"));
|
|
50
|
+
|
|
51
|
+
// nsKey/hashEnabled resolved BEFORE mapTabs build — anchor key
|
|
52
|
+
// derivation in _keyFromTrigger needs nsKey to pick the right
|
|
53
|
+
// fragment.
|
|
54
|
+
this.nsKey = (this.dom.getAttribute("data-ln-tabs-key") || this.dom.id || "").toLowerCase().trim();
|
|
55
|
+
this.hashEnabled = !!this.nsKey;
|
|
56
|
+
|
|
57
|
+
this.mapTabs = {};
|
|
58
|
+
this.mapPanels = {};
|
|
59
|
+
for (const t of this.tabs) {
|
|
60
|
+
const key = _keyFromTrigger(t, this.nsKey);
|
|
61
|
+
if (key) {
|
|
62
|
+
this.mapTabs[key] = t;
|
|
63
|
+
} else {
|
|
64
|
+
console.warn('[ln-tabs] Trigger has no resolvable key — needs `data-ln-tab="key"` or `<a href="#…">`.', t);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const p of this.panels) {
|
|
68
|
+
const key = (p.getAttribute("data-ln-panel") || "").toLowerCase().trim();
|
|
69
|
+
if (key) this.mapPanels[key] = p;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.defaultKey = (this.dom.getAttribute("data-ln-tabs-default") || "").toLowerCase().trim()
|
|
73
|
+
|| Object.keys(this.mapTabs)[0] || "";
|
|
74
|
+
this.autoFocus = (this.dom.getAttribute("data-ln-tabs-focus") || "true").toLowerCase() !== "false";
|
|
75
|
+
|
|
76
|
+
const self = this;
|
|
77
|
+
this._clickHandlers = [];
|
|
78
|
+
for (const t of this.tabs) {
|
|
79
|
+
if (t[DOM_ATTRIBUTE + 'Trigger']) continue;
|
|
80
|
+
const handler = function (e) {
|
|
81
|
+
if (e.ctrlKey || e.metaKey || e.button === 1) return;
|
|
82
|
+
const key = _keyFromTrigger(t, self.nsKey);
|
|
83
|
+
if (!key) return;
|
|
84
|
+
if (t.tagName === "A") e.preventDefault();
|
|
85
|
+
if (self.hashEnabled) {
|
|
86
|
+
const map = _parseHash();
|
|
87
|
+
map[self.nsKey] = key;
|
|
88
|
+
const newHash = Object.keys(map).map(function (k) { return k + ":" + map[k]; }).join("&");
|
|
89
|
+
if (location.hash === "#" + newHash) self.dom.setAttribute('data-ln-tabs-active', key);
|
|
90
|
+
else location.hash = newHash;
|
|
91
|
+
} else {
|
|
92
|
+
self.dom.setAttribute('data-ln-tabs-active', key);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
t.addEventListener("click", handler);
|
|
96
|
+
t[DOM_ATTRIBUTE + 'Trigger'] = handler;
|
|
97
|
+
self._clickHandlers.push({ el: t, handler: handler });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this._hashHandler = function () {
|
|
101
|
+
if (!self.hashEnabled) return;
|
|
102
|
+
const map = _parseHash();
|
|
103
|
+
self.dom.setAttribute('data-ln-tabs-active', self.nsKey in map ? map[self.nsKey] : self.defaultKey);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (this.hashEnabled) {
|
|
107
|
+
window.addEventListener("hashchange", this._hashHandler);
|
|
108
|
+
this._hashHandler();
|
|
109
|
+
} else {
|
|
110
|
+
let initialKey = this.defaultKey;
|
|
111
|
+
if (this.dom.hasAttribute('data-ln-persist') && !this.hashEnabled) {
|
|
112
|
+
const saved = persistGet('tabs', this.dom);
|
|
113
|
+
if (saved !== null && saved in this.mapPanels) {
|
|
114
|
+
initialKey = saved;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.dom.setAttribute('data-ln-tabs-active', initialKey);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_component.prototype._applyActive = function (key) {
|
|
122
|
+
if (!key || !(key in this.mapPanels)) key = this.defaultKey;
|
|
123
|
+
for (const k in this.mapTabs) {
|
|
124
|
+
const btn = this.mapTabs[k];
|
|
125
|
+
if (k === key) {
|
|
126
|
+
btn.setAttribute("data-active", "");
|
|
127
|
+
btn.setAttribute("aria-selected", "true");
|
|
128
|
+
} else {
|
|
129
|
+
btn.removeAttribute("data-active");
|
|
130
|
+
btn.setAttribute("aria-selected", "false");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const k in this.mapPanels) {
|
|
134
|
+
const panel = this.mapPanels[k];
|
|
135
|
+
const show = (k === key);
|
|
136
|
+
panel.classList.toggle("hidden", !show);
|
|
137
|
+
panel.setAttribute("aria-hidden", show ? "false" : "true");
|
|
138
|
+
}
|
|
139
|
+
if (this.autoFocus) {
|
|
140
|
+
const first = this.mapPanels[key]?.querySelector('input,button,select,textarea,[tabindex]:not([tabindex="-1"])');
|
|
141
|
+
if (first) setTimeout(() => first.focus({ preventScroll: true }), 0);
|
|
142
|
+
}
|
|
143
|
+
dispatch(this.dom, 'ln-tabs:change', { key: key, tab: this.mapTabs[key], panel: this.mapPanels[key] });
|
|
144
|
+
if (this.dom.hasAttribute('data-ln-persist') && !this.hashEnabled) {
|
|
145
|
+
persistSet('tabs', this.dom, key);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
_component.prototype.destroy = function () {
|
|
150
|
+
if (!this.dom[DOM_ATTRIBUTE]) return;
|
|
151
|
+
for (const { el, handler } of this._clickHandlers) {
|
|
152
|
+
el.removeEventListener("click", handler);
|
|
153
|
+
delete el[DOM_ATTRIBUTE + 'Trigger'];
|
|
154
|
+
}
|
|
155
|
+
if (this.hashEnabled) {
|
|
156
|
+
window.removeEventListener("hashchange", this._hashHandler);
|
|
157
|
+
}
|
|
158
|
+
dispatch(this.dom, 'ln-tabs:destroyed', { target: this.dom });
|
|
159
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ─── Init ──────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-tabs', {
|
|
165
|
+
extraAttributes: ['data-ln-tabs-active'],
|
|
166
|
+
onAttributeChange: function (el) {
|
|
167
|
+
const key = el.getAttribute('data-ln-tabs-active');
|
|
168
|
+
el[DOM_ATTRIBUTE]._applyActive(key);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
})();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ln-time
|
|
2
|
+
|
|
3
|
+
A zero-dependency, progressive **Timezone-Aware Timestamp Formatter** that localizes standard HTML `<time>` elements using native browser APIs.
|
|
4
|
+
|
|
5
|
+
It replaces server-rendered fallback text with localized, timezone-aware date and time formats, performing automatic live updates for relative timestamps using a single shared timer scheduler.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🧭 Philosophy & Architecture
|
|
10
|
+
|
|
11
|
+
1. **Progressive Enhancement:** Server layouts (e.g. Laravel Blade templates) render standard text fallbacks inside native `<time>` elements. If JavaScript fails or is blocked, the original text is preserved; when active, the component compiles and overwrites it.
|
|
12
|
+
2. **Standard-Based API Concurrency:** Built entirely on standard browser APIs (`Intl.DateTimeFormat` and `Intl.RelativeTimeFormat`). It bypasses heavy date-parsing libraries, leveraging the browser's native locale and timezone mappings.
|
|
13
|
+
3. **Shared Interval Loop:** Dynamic relative timestamps (e.g., "5 minutes ago") auto-update every 60 seconds. Instead of spinning up individual intervals, a single global interval manager coordinates all active elements, automatically purging detached nodes.
|
|
14
|
+
4. **Reactive State Resolution:** All state changes are governed by native HTML attributes. Changing `datetime` or `data-ln-time` triggers automatic DOM re-renders via `MutationObserver` synchronization.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 📦 Minimal Blueprint
|
|
19
|
+
|
|
20
|
+
### Static Localized Date
|
|
21
|
+
Provide a Unix timestamp (in seconds) via `datetime` and specify the formatting mode.
|
|
22
|
+
```html
|
|
23
|
+
<!-- Server renders a fallback, JS enhances with user timezone and locale -->
|
|
24
|
+
<time data-ln-time="full" datetime="1736952600">January 15, 2025</time>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Auto-Updating Relative Timestamp
|
|
28
|
+
```html
|
|
29
|
+
<!-- Automatically updates every 60s (e.g. "3 minutes ago") -->
|
|
30
|
+
<time data-ln-time="relative" datetime="1736952600">Jan 15</time>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Custom Localized Override
|
|
34
|
+
```html
|
|
35
|
+
<!-- Localizes the time string to Macedonian regardless of browser defaults -->
|
|
36
|
+
<time data-ln-time="full" datetime="1736952600" data-ln-time-locale="mk">Јануари 15, 2025</time>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🛠️ Declarative API Contract
|
|
42
|
+
|
|
43
|
+
### HTML Attributes
|
|
44
|
+
|
|
45
|
+
| Attribute | Elements | Description |
|
|
46
|
+
| :--- | :--- | :--- |
|
|
47
|
+
| `data-ln-time` | `<time>` | Format mode: `relative`, `short`, `full`, `date`, `time`. |
|
|
48
|
+
| `datetime` | `<time>` | **Required**. Unix timestamp in **seconds** (not milliseconds). |
|
|
49
|
+
| `data-ln-time-locale`| `<time>` | Opt-in. Force-overrides translation locale (e.g. `"de"`, `"mk"`). |
|
|
50
|
+
|
|
51
|
+
### Format Modes
|
|
52
|
+
|
|
53
|
+
| Mode | Output Example | Intl Engine |
|
|
54
|
+
| :--- | :--- | :--- |
|
|
55
|
+
| `relative` | `"3 hr. ago"`, `"just now"`, `"in 2 hr."` | `Intl.RelativeTimeFormat` |
|
|
56
|
+
| `short` | `"Jan 15"` (current year) / `"Jan 15, 2024"` | `{ month: 'short', day: 'numeric' }` |
|
|
57
|
+
| `full` | `"January 15, 2025 at 2:30 PM"` | `{ dateStyle: 'long', timeStyle: 'short' }` |
|
|
58
|
+
| `date` | `"Jan 15, 2025"` | `{ dateStyle: 'medium' }` |
|
|
59
|
+
| `time` | `"2:30 PM"` | `{ timeStyle: 'short' }` |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## ⚡ Live Relative Thresholds
|
|
64
|
+
|
|
65
|
+
| Elapsed Time | Evaluated Unit | Output Example (English) |
|
|
66
|
+
| :--- | :--- | :--- |
|
|
67
|
+
| `< 10 seconds` | — | `"now"` |
|
|
68
|
+
| `< 60 seconds` | `second` | `"45 sec. ago"` |
|
|
69
|
+
| `< 60 minutes` | `minute` | `"5 min. ago"` |
|
|
70
|
+
| `< 24 hours` | `hour` | `"3 hr. ago"` |
|
|
71
|
+
| `< 7 days` | `day` | `"2 days ago"` |
|
|
72
|
+
| `< 30 days` | `week` | `"2 wk. ago"` |
|
|
73
|
+
| `>= 30 days` | — | Falls back to static `short` date. |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## ⚠️ Common Pitfalls
|
|
78
|
+
|
|
79
|
+
- **Passing Millisecond Timestamps:** Standard database fields and JS date objects often return milliseconds (13 digits). `ln-time` maps strictly to Unix seconds (10 digits). Divide milliseconds by `1000` before rendering.
|
|
80
|
+
- **Using ISO Strings:** `datetime="2025-01-15T14:30:00Z"` is not parsed. The component skips non-numeric values, leaving the original fallback text.
|
|
81
|
+
- **Empty `datetime` Attributes:** If the `datetime` attribute is omitted or empty, the component will skip formatting to preserve server fallback strings.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function x(r,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){x(r,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}r()}function S(r,e,s,y){if(r.nodeType!==1)return;const m=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",g=Array.from(r.querySelectorAll(m));r.matches&&r.matches(m)&&g.push(r);for(const o of g)o[s]||(o[s]=new y(o))}function O(r,e,s,y,w={}){const m=w.extraAttributes||[],g=w.onAttributeChange||null,o=w.onInit||null;function d(p){const c=p||document.body;S(c,r,e,s),o&&o(c)}return x(function(){const p=new MutationObserver(function(_){for(let h=0;h<_.length;h++){const u=_[h];if(u.type==="childList")for(let A=0;A<u.addedNodes.length;A++){const M=u.addedNodes[A];M.nodeType===1&&(S(M,r,e,s),o&&o(M))}else u.type==="attributes"&&(g&&u.target[e]?g(u.target,u.attributeName):(S(u.target,r,e,s),o&&o(u.target)))}});let c=[];if(r.indexOf("[")!==-1){const _=/\[([\w-]+)/g;let h;for(;(h=_.exec(r))!==null;)c.push(h[1])}else c.push(r);p.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:c.concat(m)})},y),window[e]=d,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){d(document.body)}):d(document.body),d}const I={};function T(r,e){I[r]=e}function k(r){return I[r]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=T,window.lnCore.getDataMapper=k),(function(){const r="data-ln-time",e="lnTime";if(window[e]!==void 0)return;const s={},y={};function w(t){return t.getAttribute("data-ln-time-locale")||document.documentElement.lang||void 0}function m(t,n){const f=(t||"")+"|"+JSON.stringify(n);return s[f]||(s[f]=new Intl.DateTimeFormat(t,n)),s[f]}function g(t){const n=t||"";return y[n]||(y[n]=new Intl.RelativeTimeFormat(t,{numeric:"auto",style:"narrow"})),y[n]}const o=new Set;let d=null;function p(){d||(d=setInterval(_,6e4))}function c(){d&&(clearInterval(d),d=null)}function _(){for(const t of o){if(!document.body.contains(t.dom)){o.delete(t);continue}D(t)}o.size===0&&c()}function h(t,n){return m(n,{dateStyle:"long",timeStyle:"short"}).format(t)}function u(t,n){const f=new Date,l={month:"short",day:"numeric"};return t.getFullYear()!==f.getFullYear()&&(l.year="numeric"),m(n,l).format(t)}function A(t,n){return m(n,{dateStyle:"medium"}).format(t)}function M(t,n){return m(n,{timeStyle:"short"}).format(t)}function E(t,n){const f=Math.floor(Date.now()/1e3),b=Math.floor(t.getTime()/1e3)-f,i=Math.abs(b);if(i<10)return g(n).format(0,"second");let a,v;if(i<60)a="second",v=b;else if(i<3600)a="minute",v=Math.round(b/60);else if(i<86400)a="hour",v=Math.round(b/3600);else if(i<604800)a="day",v=Math.round(b/86400);else if(i<2592e3)a="week",v=Math.round(b/604800);else return u(t,n);return g(n).format(v,a)}function D(t){const n=t.dom.getAttribute("datetime");if(!n)return;const f=Number(n);if(isNaN(f))return;const l=new Date(f*1e3),b=t.dom.getAttribute(r)||"short",i=w(t.dom);let a;switch(b){case"relative":a=E(l,i);break;case"full":a=h(l,i);break;case"date":a=A(l,i);break;case"time":a=M(l,i);break;default:a=u(l,i);break}t.dom.textContent=a,b!=="full"&&(t.dom.title=h(l,i))}function C(t){return this.dom=t,D(this),t.getAttribute(r)==="relative"&&(o.add(this),p()),this}C.prototype.render=function(){D(this)},C.prototype.destroy=function(){o.delete(this),o.size===0&&c(),delete this.dom[e]};function L(t){const n=t[e];if(!n)return;t.getAttribute(r)==="relative"?(o.add(n),p()):(o.delete(n),o.size===0&&c()),D(n)}function N(t){t.nodeType===1&&t.hasAttribute&&t.hasAttribute(r)&&t[e]&&D(t[e])}O(r,e,C,"ln-time",{extraAttributes:["datetime"],onAttributeChange:L,onInit:N})})()})();
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { registerComponent } from '../../ln-core';
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const DOM_SELECTOR = 'data-ln-time';
|
|
5
|
+
const DOM_ATTRIBUTE = 'lnTime';
|
|
6
|
+
|
|
7
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
8
|
+
|
|
9
|
+
// ─── Formatter Cache ──────────────────────────────────────
|
|
10
|
+
const _formatters = {};
|
|
11
|
+
const _relativeFormatters = {};
|
|
12
|
+
|
|
13
|
+
function _getLocale(el) {
|
|
14
|
+
return el.getAttribute('data-ln-time-locale')
|
|
15
|
+
|| document.documentElement.lang
|
|
16
|
+
|| undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _getFormatter(locale, options) {
|
|
20
|
+
const key = (locale || '') + '|' + JSON.stringify(options);
|
|
21
|
+
if (!_formatters[key]) {
|
|
22
|
+
_formatters[key] = new Intl.DateTimeFormat(locale, options);
|
|
23
|
+
}
|
|
24
|
+
return _formatters[key];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _getRelativeFormatter(locale) {
|
|
28
|
+
const key = locale || '';
|
|
29
|
+
if (!_relativeFormatters[key]) {
|
|
30
|
+
_relativeFormatters[key] = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' });
|
|
31
|
+
}
|
|
32
|
+
return _relativeFormatters[key];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Auto-Update Pool ─────────────────────────────────────
|
|
36
|
+
const _relativeElements = new Set();
|
|
37
|
+
let _intervalId = null;
|
|
38
|
+
|
|
39
|
+
function _startInterval() {
|
|
40
|
+
if (_intervalId) return;
|
|
41
|
+
_intervalId = setInterval(_tickRelative, 60000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _stopInterval() {
|
|
45
|
+
if (_intervalId) {
|
|
46
|
+
clearInterval(_intervalId);
|
|
47
|
+
_intervalId = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _tickRelative() {
|
|
52
|
+
for (const instance of _relativeElements) {
|
|
53
|
+
if (!document.body.contains(instance.dom)) {
|
|
54
|
+
_relativeElements.delete(instance);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
_render(instance);
|
|
58
|
+
}
|
|
59
|
+
if (_relativeElements.size === 0) _stopInterval();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Formatting ───────────────────────────────────────────
|
|
63
|
+
function _formatFull(date, locale) {
|
|
64
|
+
return _getFormatter(locale, { dateStyle: 'long', timeStyle: 'short' }).format(date);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _formatShort(date, locale) {
|
|
68
|
+
const now = new Date();
|
|
69
|
+
const options = { month: 'short', day: 'numeric' };
|
|
70
|
+
if (date.getFullYear() !== now.getFullYear()) {
|
|
71
|
+
options.year = 'numeric';
|
|
72
|
+
}
|
|
73
|
+
return _getFormatter(locale, options).format(date);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _formatDate(date, locale) {
|
|
77
|
+
return _getFormatter(locale, { dateStyle: 'medium' }).format(date);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _formatTime(date, locale) {
|
|
81
|
+
return _getFormatter(locale, { timeStyle: 'short' }).format(date);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _formatRelative(date, locale) {
|
|
85
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
86
|
+
const thenSec = Math.floor(date.getTime() / 1000);
|
|
87
|
+
const diff = thenSec - nowSec;
|
|
88
|
+
const absDiff = Math.abs(diff);
|
|
89
|
+
|
|
90
|
+
if (absDiff < 10) return _getRelativeFormatter(locale).format(0, 'second');
|
|
91
|
+
|
|
92
|
+
let unit, value;
|
|
93
|
+
if (absDiff < 60) {
|
|
94
|
+
unit = 'second'; value = diff;
|
|
95
|
+
} else if (absDiff < 3600) {
|
|
96
|
+
unit = 'minute'; value = Math.round(diff / 60);
|
|
97
|
+
} else if (absDiff < 86400) {
|
|
98
|
+
unit = 'hour'; value = Math.round(diff / 3600);
|
|
99
|
+
} else if (absDiff < 604800) {
|
|
100
|
+
unit = 'day'; value = Math.round(diff / 86400);
|
|
101
|
+
} else if (absDiff < 2592000) {
|
|
102
|
+
unit = 'week'; value = Math.round(diff / 604800);
|
|
103
|
+
} else {
|
|
104
|
+
return _formatShort(date, locale);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return _getRelativeFormatter(locale).format(value, unit);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Render ───────────────────────────────────────────────
|
|
111
|
+
function _render(instance) {
|
|
112
|
+
const raw = instance.dom.getAttribute('datetime');
|
|
113
|
+
if (!raw) return;
|
|
114
|
+
|
|
115
|
+
const timestamp = Number(raw);
|
|
116
|
+
if (isNaN(timestamp)) return;
|
|
117
|
+
|
|
118
|
+
const date = new Date(timestamp * 1000);
|
|
119
|
+
const mode = instance.dom.getAttribute(DOM_SELECTOR) || 'short';
|
|
120
|
+
const locale = _getLocale(instance.dom);
|
|
121
|
+
let text;
|
|
122
|
+
|
|
123
|
+
switch (mode) {
|
|
124
|
+
case 'relative': text = _formatRelative(date, locale); break;
|
|
125
|
+
case 'full': text = _formatFull(date, locale); break;
|
|
126
|
+
case 'date': text = _formatDate(date, locale); break;
|
|
127
|
+
case 'time': text = _formatTime(date, locale); break;
|
|
128
|
+
default: text = _formatShort(date, locale); break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
instance.dom.textContent = text;
|
|
132
|
+
if (mode !== 'full') {
|
|
133
|
+
instance.dom.title = _formatFull(date, locale);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Component ────────────────────────────────────────────
|
|
138
|
+
function _constructor(dom) {
|
|
139
|
+
this.dom = dom;
|
|
140
|
+
_render(this);
|
|
141
|
+
|
|
142
|
+
if (dom.getAttribute(DOM_SELECTOR) === 'relative') {
|
|
143
|
+
_relativeElements.add(this);
|
|
144
|
+
_startInterval();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_constructor.prototype.render = function () {
|
|
151
|
+
_render(this);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
_constructor.prototype.destroy = function () {
|
|
155
|
+
_relativeElements.delete(this);
|
|
156
|
+
if (_relativeElements.size === 0) _stopInterval();
|
|
157
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// ─── Attribute / Mutation Hooks ───────────────────────────
|
|
161
|
+
|
|
162
|
+
function _onAttributeChange(el) {
|
|
163
|
+
const instance = el[DOM_ATTRIBUTE];
|
|
164
|
+
if (!instance) return;
|
|
165
|
+
const mode = el.getAttribute(DOM_SELECTOR);
|
|
166
|
+
if (mode === 'relative') {
|
|
167
|
+
_relativeElements.add(instance);
|
|
168
|
+
_startInterval();
|
|
169
|
+
} else {
|
|
170
|
+
_relativeElements.delete(instance);
|
|
171
|
+
if (_relativeElements.size === 0) _stopInterval();
|
|
172
|
+
}
|
|
173
|
+
_render(instance);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _onInit(node) {
|
|
177
|
+
// Re-render any data-ln-time element in the mutation target subtree.
|
|
178
|
+
// Covers `datetime` extra-attribute changes (extras don't fire onAttributeChange).
|
|
179
|
+
if (node.nodeType !== 1) return;
|
|
180
|
+
if (node.hasAttribute && node.hasAttribute(DOM_SELECTOR) && node[DOM_ATTRIBUTE]) {
|
|
181
|
+
_render(node[DOM_ATTRIBUTE]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Registration ─────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _constructor, 'ln-time', {
|
|
188
|
+
extraAttributes: ['datetime'],
|
|
189
|
+
onAttributeChange: _onAttributeChange,
|
|
190
|
+
onInit: _onInit
|
|
191
|
+
});
|
|
192
|
+
})();
|