@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,150 @@
|
|
|
1
|
+
# `data-ln-api-connector`
|
|
2
|
+
|
|
3
|
+
A zero-dependency, Local-First sync transport component that implements the Transport Gateway pattern of `ln-ashlar`.
|
|
4
|
+
|
|
5
|
+
This component encapsulates all connection parameters (base URLs, auth tokens, headers, paths) and provides a declarative, event-driven, or programmatic way to talk to any RESTful or JSON API backend. It isolates networking concerns completely, making your cache store (`data-ln-data-store`) and visual presentation layers fully network-agnostic.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Declarative DOM Setup
|
|
10
|
+
|
|
11
|
+
Place the connector inside your parent coordinator element alongside your store:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<div data-ln-data-coordinator="documents">
|
|
15
|
+
<!-- Storage Layer Cache (Blind to networking) -->
|
|
16
|
+
<div data-ln-data-store
|
|
17
|
+
data-ln-store-indexes="status,updated_at">
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Transport Gateway (REST / API Connector) -->
|
|
21
|
+
<div data-ln-api-connector
|
|
22
|
+
data-ln-api-base-url="https://api.livenetworks.com/v1"
|
|
23
|
+
data-ln-api-path="/documents"
|
|
24
|
+
data-ln-api-headers='{"Authorization": "Bearer tok_123"}'>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Attributes
|
|
32
|
+
|
|
33
|
+
All attributes are dynamically observed. Any runtime changes to these attributes instantly update the connector's internal configurations.
|
|
34
|
+
|
|
35
|
+
| Attribute | Category | Description |
|
|
36
|
+
|-----------|----------|-------------|
|
|
37
|
+
| `data-ln-api-connector` | Selector | Creates the component instance. Optional value serves as name. |
|
|
38
|
+
| `data-ln-api-base-url` | Connection | The base URL of the API gateway (e.g. `https://api.example.com/v1` or `/api`). Fallback: `data-ln-api-connector-base-url`, `data-ln-rest-base-url`. |
|
|
39
|
+
| `data-ln-api-path` | Connection | Resource path endpoint (e.g. `/documents`). Fallback: `data-ln-api-connector-path`, `data-ln-rest-path`. |
|
|
40
|
+
| `data-ln-api-headers` | Credentials | JSON-formatted string of request headers (e.g. `{"Authorization": "Bearer tok_123"}`). Fallback: `data-ln-api-connector-headers`, `data-ln-rest-headers`. |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## JavaScript API Methods
|
|
45
|
+
|
|
46
|
+
You can access the instance methods directly on the element via the `.lnApiConnector` or `.lnConnector` properties:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const connectorEl = document.querySelector('[data-ln-api-connector]');
|
|
50
|
+
const connector = connectorEl.lnApiConnector; // or connectorEl.lnConnector
|
|
51
|
+
|
|
52
|
+
// 1. Fetch changed records since a Unix timestamp (delta protocol)
|
|
53
|
+
connector.fetchDelta(1736952600)
|
|
54
|
+
.then(data => console.log('Sync arrays loaded:', data));
|
|
55
|
+
|
|
56
|
+
// 2. Create a new record
|
|
57
|
+
connector.create({ title: 'New Document', status: 'Draft' })
|
|
58
|
+
.then(record => console.log('Created record:', record));
|
|
59
|
+
|
|
60
|
+
// 3. Update an existing record
|
|
61
|
+
connector.update(42, { title: 'Updated Title' })
|
|
62
|
+
.then(record => console.log('Updated record:', record));
|
|
63
|
+
|
|
64
|
+
// 4. Delete a record
|
|
65
|
+
connector.delete(42)
|
|
66
|
+
.then(res => console.log('Deleted:', res));
|
|
67
|
+
|
|
68
|
+
// 5. Bulk Delete records
|
|
69
|
+
connector.bulkDelete([17, 23])
|
|
70
|
+
.then(res => console.log('Bulk deleted:', res));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## DOM Events
|
|
76
|
+
|
|
77
|
+
### Commands (Dispatched TO the connector)
|
|
78
|
+
|
|
79
|
+
You can trigger mutations and fetches asynchronously by dispatching standard events directly on the connector DOM element. All events are supported in both `ln-api-connector` and legacy `ln-rest-connector` namespaces.
|
|
80
|
+
|
|
81
|
+
| Event | `detail` Payload | Description |
|
|
82
|
+
|-------|------------------|-------------|
|
|
83
|
+
| `ln-api-connector:request-sync` | `{ since }` | Triggers a delta fetch request. |
|
|
84
|
+
| `ln-api-connector:request-create` | `{ data, tempId }` | Triggers a creation request. |
|
|
85
|
+
| `ln-api-connector:request-update` | `{ id, data, expected_version }` | Triggers a PUT update (supports 409 conflict checks). |
|
|
86
|
+
| `ln-api-connector:request-delete` | `{ id }` | Triggers a deletion request. |
|
|
87
|
+
| `ln-api-connector:request-bulk-delete` | `{ ids }` | Triggers a bulk-deletion request. |
|
|
88
|
+
|
|
89
|
+
### Notifications (Emitted BY the connector)
|
|
90
|
+
|
|
91
|
+
The connector dispatches bubbles-enabled events to notify parent coordinators of response states.
|
|
92
|
+
|
|
93
|
+
| Event | `detail` Payload | Description |
|
|
94
|
+
|-------|------------------|-------------|
|
|
95
|
+
| `ln-api-connector:fetched` | `{ data, since }` | Dispatched upon successful fetch completion. |
|
|
96
|
+
| `ln-api-connector:created` | `{ record, tempId }` | Dispatched upon successful server record confirmation. |
|
|
97
|
+
| `ln-api-connector:updated` | `{ record, id }` | Dispatched upon successful server update confirmation. |
|
|
98
|
+
| `ln-api-connector:deleted` | `{ response, id }` | Dispatched upon successful server deletion. |
|
|
99
|
+
| `ln-api-connector:bulk-deleted`| `{ response, ids }` | Dispatched upon successful server bulk deletion. |
|
|
100
|
+
| `ln-api-connector:error` | `{ action, error, status, ... }` | Dispatched on failures. Includes `conflictData` if status is 409. |
|
|
101
|
+
| `ln-api-connector:config-changed` | `{ baseUrl, path, headers }` | Dispatched when configuration attributes are mutated. |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 3-Tier Integration Architecture (Coordinator Example)
|
|
106
|
+
|
|
107
|
+
Here is how a parent Coordinator links a Cache Store and the API Connector using standard DOM events:
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
(function () {
|
|
111
|
+
const parent = document.querySelector('[data-ln-data-coordinator="documents"]');
|
|
112
|
+
const storeEl = parent.querySelector('[data-ln-data-store]');
|
|
113
|
+
const connectorEl = parent.querySelector('[data-ln-api-connector]');
|
|
114
|
+
|
|
115
|
+
if (!storeEl || !connectorEl) return;
|
|
116
|
+
|
|
117
|
+
// 1. Storage needs remote sync -> forward request to Connector
|
|
118
|
+
storeEl.addEventListener('ln-store:request-remote-sync', function (e) {
|
|
119
|
+
connectorEl.dispatchEvent(new CustomEvent('ln-api-connector:request-sync', {
|
|
120
|
+
detail: { since: e.detail.since }
|
|
121
|
+
}));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 2. Connector finishes sync -> feed delta back to Storage
|
|
125
|
+
connectorEl.addEventListener('ln-api-connector:fetched', function (e) {
|
|
126
|
+
const payload = e.detail.data;
|
|
127
|
+
storeEl.lnStore.applySync(payload.data, payload.deleted || [], payload.synced_at);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// 3. Storage requests remote creation -> forward payload to Connector
|
|
131
|
+
storeEl.addEventListener('ln-store:request-remote-create', function (e) {
|
|
132
|
+
connectorEl.dispatchEvent(new CustomEvent('ln-api-connector:request-create', {
|
|
133
|
+
detail: { data: e.detail.data, tempId: e.detail.tempId }
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 4. Connector confirms creation -> swap optimistic temp records in Storage
|
|
138
|
+
connectorEl.addEventListener('ln-api-connector:created', function (e) {
|
|
139
|
+
storeEl.lnStore.confirmMutation(e.detail.tempId, e.detail.record, 'create');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 5. In case of network errors -> trigger rollback in Cache Store
|
|
143
|
+
connectorEl.addEventListener('ln-api-connector:error', function (e) {
|
|
144
|
+
if (e.detail.action === 'create') {
|
|
145
|
+
storeEl.lnStore.revertMutation(e.detail.tempId, 'create', e.detail.error);
|
|
146
|
+
}
|
|
147
|
+
// ... handle updates and deletions similarly
|
|
148
|
+
});
|
|
149
|
+
})();
|
|
150
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function c(i,o,d){i.dispatchEvent(new CustomEvent(o,{bubbles:!0,detail:d||{}}))}function E(i,o){if(!document.body){document.addEventListener("DOMContentLoaded",function(){E(i,o)}),console.warn("["+o+'] Script loaded before <body> — add "defer" to your <script> tag');return}i()}function y(i,o,d,u){if(i.nodeType!==1)return;const p=o.indexOf("[")!==-1||o.indexOf(".")!==-1||o.indexOf("#")!==-1?o:"["+o+"]",e=Array.from(i.querySelectorAll(p));i.matches&&i.matches(p)&&e.push(i);for(const n of e)n[d]||(n[d]=new u(n))}function T(i,o,d,u,h={}){const p=h.extraAttributes||[],e=h.onAttributeChange||null,n=h.onInit||null;function t(r){const a=r||document.body;y(a,i,o,d),n&&n(a)}return E(function(){const r=new MutationObserver(function(s){for(let f=0;f<s.length;f++){const l=s[f];if(l.type==="childList")for(let w=0;w<l.addedNodes.length;w++){const g=l.addedNodes[w];g.nodeType===1&&(y(g,i,o,d),n&&n(g))}else l.type==="attributes"&&(e&&l.target[o]?e(l.target,l.attributeName):(y(l.target,i,o,d),n&&n(l.target)))}});let a=[];if(i.indexOf("[")!==-1){const s=/\[([\w-]+)/g;let f;for(;(f=s.exec(i))!==null;)a.push(f[1])}else a.push(i);r.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:a.concat(p)})},u),window[o]=t,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){t(document.body)}):t(document.body),t}function m(...i){return i.filter(o=>o!=null&&o!=="").map((o,d)=>d===0?o.replace(/\/+$/,""):o.replace(/^\/+/,"").replace(/\/+$/,"")).filter(Boolean).join("/")}function b(i,o){return Object.assign({"Content-Type":"application/json",Accept:"application/json"},i,null)}function L(i,o="ln-core"){try{return i?JSON.parse(i):{}}catch(d){return console.error(`[${o}] Invalid headers JSON:`,d),{}}}const v={};function _(i,o){v[i]=o}function C(i){return v[i]||{ingress:o=>o,egress:o=>o}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=_,window.lnCore.getDataMapper=C),(function(){const i="data-ln-api-connector",o="lnApiConnector",d="lnConnector";if(window[o]!==void 0)return;function u(e){return this.dom=e,e[o]=this,e[d]=this,this.refreshConfig(),this._handlers=null,h(this),this}u.prototype.refreshConfig=function(){const e=this.dom;this.baseUrl=e.getAttribute("data-ln-api-base-url")||"",this.path=e.getAttribute("data-ln-api-path")||"",this.credentials="same-origin";const n=e.getAttribute("data-ln-api-headers")||"";this.headers=L(n,"ln-api-connector"),(n.toLowerCase().includes("authorization")||n.toLowerCase().includes("bearer")||n.toLowerCase().includes("basic"))&&console.warn("[ln-api-connector] Security Warning: Sensitive authorization credentials detected in data-ln-api-headers attribute. Storing secrets in HTML DOM attributes is highly discouraged and vulnerable to XSS credential extraction. Please use HttpOnly session cookies or a Backend Proxy Gateway instead."),c(e,"ln-api-connector:config-changed",{baseUrl:this.baseUrl,path:this.path,headers:this.headers})},u.prototype.fetchDelta=function(e){const n=this;let t=m(n.baseUrl,n.path);return e!=null&&e!==""&&(t+=(t.indexOf("?")!==-1?"&":"?")+"since="+encodeURIComponent(e)),window.fetch(t,{method:"GET",headers:b(n.headers),credentials:n.credentials}).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status+": "+r.statusText);return r.json()})},u.prototype.create=function(e){const n=this;return window.fetch(m(n.baseUrl,n.path),{method:"POST",headers:b(n.headers),credentials:n.credentials,body:JSON.stringify(e)}).then(t=>{if(!t.ok)throw new Error("HTTP "+t.status+": "+t.statusText);return t.json()})},u.prototype.update=function(e,n){const t=this;return window.fetch(m(t.baseUrl,t.path,e),{method:"PUT",headers:b(t.headers),credentials:t.credentials,body:JSON.stringify(n)}).then(r=>{if(r.ok)return r.json();if(r.status===409)return r.json().then(a=>{const s=new Error("Conflict");throw s.status=409,s.data=a,s});throw new Error("HTTP "+r.status+": "+r.statusText)})},u.prototype.delete=function(e){const n=this;return window.fetch(m(n.baseUrl,n.path,e),{method:"DELETE",headers:b(n.headers),credentials:n.credentials}).then(t=>{if(!t.ok)throw new Error("HTTP "+t.status+": "+t.statusText);return t.json()})},u.prototype.bulkDelete=function(e){const n=this;return window.fetch(m(n.baseUrl,n.path)+"/bulk-delete",{method:"DELETE",headers:b(n.headers),credentials:n.credentials,body:JSON.stringify({ids:e})}).then(t=>{if(!t.ok)throw new Error("HTTP "+t.status+": "+t.statusText);return t.json()})};function h(e){e._handlers={sync:function(t){const r=t.detail||{};e.fetchDelta(r.since).then(function(a){c(e.dom,"ln-api-connector:fetched",{data:a,since:r.since})}).catch(function(a){c(e.dom,"ln-api-connector:error",{action:"sync",error:a.message,status:a.status||0,since:r.since})})},create:function(t){const r=t.detail||{};e.create(r.data).then(function(a){c(e.dom,"ln-api-connector:created",{record:a,tempId:r.tempId})}).catch(function(a){c(e.dom,"ln-api-connector:error",{action:"create",error:a.message,status:a.status||0,tempId:r.tempId})})},update:function(t){const r=t.detail||{},a=Object.assign({},r.data);r.expected_version!==void 0&&(a.expected_version=r.expected_version),e.update(r.id,a).then(function(s){c(e.dom,"ln-api-connector:updated",{record:s,id:r.id})}).catch(function(s){c(e.dom,"ln-api-connector:error",{action:"update",error:s.message,status:s.status||0,id:r.id,conflictData:s.status===409?s.data:null})})},delete:function(t){const r=t.detail||{};e.delete(r.id).then(function(a){c(e.dom,"ln-api-connector:deleted",{response:a,id:r.id})}).catch(function(a){c(e.dom,"ln-api-connector:error",{action:"delete",error:a.message,status:a.status||0,id:r.id})})},bulkDelete:function(t){const r=t.detail||{};e.bulkDelete(r.ids).then(function(a){c(e.dom,"ln-api-connector:bulk-deleted",{response:a,ids:r.ids})}).catch(function(a){c(e.dom,"ln-api-connector:error",{action:"bulk-delete",error:a.message,status:a.status||0,ids:r.ids})})}},["ln-api-connector","ln-rest-connector"].forEach(function(t){e.dom.addEventListener(t+":request-sync",e._handlers.sync),e.dom.addEventListener(t+":request-fetch",e._handlers.sync),e.dom.addEventListener(t+":request-create",e._handlers.create),e.dom.addEventListener(t+":request-update",e._handlers.update),e.dom.addEventListener(t+":request-delete",e._handlers.delete),e.dom.addEventListener(t+":request-bulk-delete",e._handlers.bulkDelete)})}u.prototype.destroy=function(){if(!this.dom[o])return;const e=this;e._handlers&&(["ln-api-connector","ln-rest-connector"].forEach(function(t){e.dom.removeEventListener(t+":request-sync",e._handlers.sync),e.dom.removeEventListener(t+":request-fetch",e._handlers.sync),e.dom.removeEventListener(t+":request-create",e._handlers.create),e.dom.removeEventListener(t+":request-update",e._handlers.update),e.dom.removeEventListener(t+":request-delete",e._handlers.delete),e.dom.removeEventListener(t+":request-bulk-delete",e._handlers.bulkDelete)}),e._handlers=null),c(this.dom,"ln-api-connector:destroyed",{target:this.dom}),delete this.dom[o],delete this.dom[d]};function p(e){const n=e[o];n&&n.refreshConfig()}T(i,o,u,"ln-api-connector",{extraAttributes:["data-ln-api-base-url","data-ln-api-path","data-ln-api-headers"],onAttributeChange:p})})()})();
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { registerComponent, dispatch, buildUrl, getHeaders, parseHeaders } from '../../ln-core';
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const DOM_SELECTOR = 'data-ln-api-connector';
|
|
5
|
+
const DOM_ATTRIBUTE = 'lnApiConnector';
|
|
6
|
+
const DOM_ALIAS = 'lnConnector';
|
|
7
|
+
|
|
8
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
9
|
+
|
|
10
|
+
// ─── Component Constructor ─────────────────────────────
|
|
11
|
+
|
|
12
|
+
function _component(dom) {
|
|
13
|
+
this.dom = dom;
|
|
14
|
+
dom[DOM_ATTRIBUTE] = this;
|
|
15
|
+
dom[DOM_ALIAS] = this; // Set alias for compatibility
|
|
16
|
+
|
|
17
|
+
this.refreshConfig();
|
|
18
|
+
this._handlers = null;
|
|
19
|
+
_bindEvents(this);
|
|
20
|
+
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Refresh Config from DOM Attributes ─────────────────
|
|
25
|
+
|
|
26
|
+
_component.prototype.refreshConfig = function () {
|
|
27
|
+
const dom = this.dom;
|
|
28
|
+
|
|
29
|
+
this.baseUrl = dom.getAttribute('data-ln-api-base-url') || '';
|
|
30
|
+
this.path = dom.getAttribute('data-ln-api-path') || '';
|
|
31
|
+
this.credentials = 'same-origin';
|
|
32
|
+
|
|
33
|
+
const rawHeaders = dom.getAttribute('data-ln-api-headers') || '';
|
|
34
|
+
this.headers = parseHeaders(rawHeaders, 'ln-api-connector');
|
|
35
|
+
|
|
36
|
+
if (rawHeaders.toLowerCase().includes('authorization') || rawHeaders.toLowerCase().includes('bearer') || rawHeaders.toLowerCase().includes('basic')) {
|
|
37
|
+
console.warn('[ln-api-connector] Security Warning: Sensitive authorization credentials detected in data-ln-api-headers attribute. Storing secrets in HTML DOM attributes is highly discouraged and vulnerable to XSS credential extraction. Please use HttpOnly session cookies or a Backend Proxy Gateway instead.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
dispatch(dom, 'ln-api-connector:config-changed', {
|
|
41
|
+
baseUrl: this.baseUrl,
|
|
42
|
+
path: this.path,
|
|
43
|
+
headers: this.headers
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ─── JS API Methods (Promises) ──────────────────────────
|
|
48
|
+
|
|
49
|
+
_component.prototype.fetchDelta = function (since) {
|
|
50
|
+
const self = this;
|
|
51
|
+
let url = buildUrl(self.baseUrl, self.path);
|
|
52
|
+
|
|
53
|
+
if (since !== undefined && since !== null && since !== '') {
|
|
54
|
+
url += (url.indexOf('?') !== -1 ? '&' : '?') + 'since=' + encodeURIComponent(since);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return window.fetch(url, { method: 'GET', headers: getHeaders(self.headers), credentials: self.credentials })
|
|
58
|
+
.then(res => {
|
|
59
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
|
|
60
|
+
return res.json();
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
_component.prototype.create = function (payload) {
|
|
65
|
+
const self = this;
|
|
66
|
+
return window.fetch(buildUrl(self.baseUrl, self.path), {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: getHeaders(self.headers),
|
|
69
|
+
credentials: self.credentials,
|
|
70
|
+
body: JSON.stringify(payload)
|
|
71
|
+
})
|
|
72
|
+
.then(res => {
|
|
73
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
|
|
74
|
+
return res.json();
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
_component.prototype.update = function (id, payload) {
|
|
79
|
+
const self = this;
|
|
80
|
+
return window.fetch(buildUrl(self.baseUrl, self.path, id), {
|
|
81
|
+
method: 'PUT',
|
|
82
|
+
headers: getHeaders(self.headers),
|
|
83
|
+
credentials: self.credentials,
|
|
84
|
+
body: JSON.stringify(payload)
|
|
85
|
+
})
|
|
86
|
+
.then(res => {
|
|
87
|
+
if (res.ok) return res.json();
|
|
88
|
+
if (res.status === 409) return res.json().then(data => {
|
|
89
|
+
const err = new Error('Conflict');
|
|
90
|
+
err.status = 409;
|
|
91
|
+
err.data = data;
|
|
92
|
+
throw err;
|
|
93
|
+
});
|
|
94
|
+
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
_component.prototype.delete = function (id) {
|
|
99
|
+
const self = this;
|
|
100
|
+
return window.fetch(buildUrl(self.baseUrl, self.path, id), {
|
|
101
|
+
method: 'DELETE',
|
|
102
|
+
headers: getHeaders(self.headers),
|
|
103
|
+
credentials: self.credentials
|
|
104
|
+
})
|
|
105
|
+
.then(res => {
|
|
106
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
|
|
107
|
+
return res.json();
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
_component.prototype.bulkDelete = function (ids) {
|
|
112
|
+
const self = this;
|
|
113
|
+
return window.fetch(buildUrl(self.baseUrl, self.path) + '/bulk-delete', {
|
|
114
|
+
method: 'DELETE',
|
|
115
|
+
headers: getHeaders(self.headers),
|
|
116
|
+
credentials: self.credentials,
|
|
117
|
+
body: JSON.stringify({ ids: ids })
|
|
118
|
+
})
|
|
119
|
+
.then(res => {
|
|
120
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
|
|
121
|
+
return res.json();
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ─── DOM Event Routing ────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function _bindEvents(self) {
|
|
128
|
+
self._handlers = {
|
|
129
|
+
sync: function (e) {
|
|
130
|
+
const detail = e.detail || {};
|
|
131
|
+
self.fetchDelta(detail.since)
|
|
132
|
+
.then(function (data) {
|
|
133
|
+
dispatch(self.dom, 'ln-api-connector:fetched', { data: data, since: detail.since });
|
|
134
|
+
})
|
|
135
|
+
.catch(function (err) {
|
|
136
|
+
dispatch(self.dom, 'ln-api-connector:error', {
|
|
137
|
+
action: 'sync',
|
|
138
|
+
error: err.message,
|
|
139
|
+
status: err.status || 0,
|
|
140
|
+
since: detail.since
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
create: function (e) {
|
|
145
|
+
const detail = e.detail || {};
|
|
146
|
+
self.create(detail.data)
|
|
147
|
+
.then(function (record) {
|
|
148
|
+
dispatch(self.dom, 'ln-api-connector:created', { record: record, tempId: detail.tempId });
|
|
149
|
+
})
|
|
150
|
+
.catch(function (err) {
|
|
151
|
+
dispatch(self.dom, 'ln-api-connector:error', {
|
|
152
|
+
action: 'create',
|
|
153
|
+
error: err.message,
|
|
154
|
+
status: err.status || 0,
|
|
155
|
+
tempId: detail.tempId
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
update: function (e) {
|
|
160
|
+
const detail = e.detail || {};
|
|
161
|
+
// Merge expected_version into payload for conflict checking
|
|
162
|
+
const payload = Object.assign({}, detail.data);
|
|
163
|
+
if (detail.expected_version !== undefined) {
|
|
164
|
+
payload.expected_version = detail.expected_version;
|
|
165
|
+
}
|
|
166
|
+
self.update(detail.id, payload)
|
|
167
|
+
.then(function (record) {
|
|
168
|
+
dispatch(self.dom, 'ln-api-connector:updated', { record: record, id: detail.id });
|
|
169
|
+
})
|
|
170
|
+
.catch(function (err) {
|
|
171
|
+
dispatch(self.dom, 'ln-api-connector:error', {
|
|
172
|
+
action: 'update',
|
|
173
|
+
error: err.message,
|
|
174
|
+
status: err.status || 0,
|
|
175
|
+
id: detail.id,
|
|
176
|
+
conflictData: err.status === 409 ? err.data : null
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
delete: function (e) {
|
|
181
|
+
const detail = e.detail || {};
|
|
182
|
+
self.delete(detail.id)
|
|
183
|
+
.then(function (res) {
|
|
184
|
+
dispatch(self.dom, 'ln-api-connector:deleted', { response: res, id: detail.id });
|
|
185
|
+
})
|
|
186
|
+
.catch(function (err) {
|
|
187
|
+
dispatch(self.dom, 'ln-api-connector:error', {
|
|
188
|
+
action: 'delete',
|
|
189
|
+
error: err.message,
|
|
190
|
+
status: err.status || 0,
|
|
191
|
+
id: detail.id
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
bulkDelete: function (e) {
|
|
196
|
+
const detail = e.detail || {};
|
|
197
|
+
self.bulkDelete(detail.ids)
|
|
198
|
+
.then(function (res) {
|
|
199
|
+
dispatch(self.dom, 'ln-api-connector:bulk-deleted', { response: res, ids: detail.ids });
|
|
200
|
+
})
|
|
201
|
+
.catch(function (err) {
|
|
202
|
+
dispatch(self.dom, 'ln-api-connector:error', {
|
|
203
|
+
action: 'bulk-delete',
|
|
204
|
+
error: err.message,
|
|
205
|
+
status: err.status || 0,
|
|
206
|
+
ids: detail.ids
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Support both general and REST namespaces for incoming requests
|
|
213
|
+
const namespaces = ['ln-api-connector', 'ln-rest-connector'];
|
|
214
|
+
namespaces.forEach(function (ns) {
|
|
215
|
+
self.dom.addEventListener(ns + ':request-sync', self._handlers.sync);
|
|
216
|
+
self.dom.addEventListener(ns + ':request-fetch', self._handlers.sync);
|
|
217
|
+
self.dom.addEventListener(ns + ':request-create', self._handlers.create);
|
|
218
|
+
self.dom.addEventListener(ns + ':request-update', self._handlers.update);
|
|
219
|
+
self.dom.addEventListener(ns + ':request-delete', self._handlers.delete);
|
|
220
|
+
self.dom.addEventListener(ns + ':request-bulk-delete', self._handlers.bulkDelete);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_component.prototype.destroy = function () {
|
|
225
|
+
if (!this.dom[DOM_ATTRIBUTE]) return;
|
|
226
|
+
|
|
227
|
+
const self = this;
|
|
228
|
+
if (self._handlers) {
|
|
229
|
+
const namespaces = ['ln-api-connector', 'ln-rest-connector'];
|
|
230
|
+
namespaces.forEach(function (ns) {
|
|
231
|
+
self.dom.removeEventListener(ns + ':request-sync', self._handlers.sync);
|
|
232
|
+
self.dom.removeEventListener(ns + ':request-fetch', self._handlers.sync);
|
|
233
|
+
self.dom.removeEventListener(ns + ':request-create', self._handlers.create);
|
|
234
|
+
self.dom.removeEventListener(ns + ':request-update', self._handlers.update);
|
|
235
|
+
self.dom.removeEventListener(ns + ':request-delete', self._handlers.delete);
|
|
236
|
+
self.dom.removeEventListener(ns + ':request-bulk-delete', self._handlers.bulkDelete);
|
|
237
|
+
});
|
|
238
|
+
self._handlers = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
dispatch(this.dom, 'ln-api-connector:destroyed', { target: this.dom });
|
|
242
|
+
|
|
243
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
244
|
+
delete this.dom[DOM_ALIAS];
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// ─── Attribute Sync ────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function _syncAttribute(el) {
|
|
250
|
+
const instance = el[DOM_ATTRIBUTE];
|
|
251
|
+
if (!instance) return;
|
|
252
|
+
instance.refreshConfig();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Registration ──────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-api-connector', {
|
|
258
|
+
extraAttributes: [
|
|
259
|
+
'data-ln-api-base-url',
|
|
260
|
+
'data-ln-api-path',
|
|
261
|
+
'data-ln-api-headers'
|
|
262
|
+
],
|
|
263
|
+
onAttributeChange: _syncAttribute
|
|
264
|
+
});
|
|
265
|
+
})();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# ln-autoresize
|
|
2
|
+
|
|
3
|
+
A zero-dependency, high-performance **UX Helper Primitive** (~47 lines of JavaScript) that dynamically resizes a `<textarea>` to track its content. It grows as the user types and collapses instantly as text is deleted.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🧭 Philosophy & Architecture
|
|
8
|
+
|
|
9
|
+
1. **Platform-First Execution:** Instead of introducing heavy observers or layouts, `ln-autoresize` hooks directly into the browser's native layout engine. By resetting height to `auto` before reading `scrollHeight` inside the event loop, it forces a synchronous, flicker-free layout calculation.
|
|
10
|
+
2. **Strict Concern Scope:** The primitive is stateless. Ceiling limits, minimum bounds, and manual resize handles are defined exclusively via standard CSS classes. The script only handles active observation.
|
|
11
|
+
3. **Reactive Synchronization:** Data flows strictly through DOM events. Programmatic changes must trigger standard events (`input` / `change`) so that dependent primitives can adapt in synchrony.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 📦 Minimal Blueprint
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<textarea data-ln-autoresize rows="1" placeholder="Type here..."></textarea>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
To cap growth at a maximum height and enable scroll past it, pair the attribute with pure CSS:
|
|
22
|
+
|
|
23
|
+
```scss
|
|
24
|
+
textarea[data-ln-autoresize] {
|
|
25
|
+
resize: none; // Removes conflicting manual drag handle
|
|
26
|
+
max-height: 6rem; // Defines height ceiling
|
|
27
|
+
overflow-y: auto; // Reveals scrollbar at ceiling
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> [!TIP]
|
|
32
|
+
> Always set `rows="1"` on the HTML element. This ensures the initial empty height matches the post-initialization state and prevents visual snap-shut on first paint.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 🛠️ Declarative API Contract
|
|
37
|
+
|
|
38
|
+
### HTML Attributes
|
|
39
|
+
|
|
40
|
+
| Attribute | Elements | Description |
|
|
41
|
+
| :--- | :--- | :--- |
|
|
42
|
+
| `data-ln-autoresize` | `<textarea>` | Observation marker. Presence creates the instance and runs initial measurement. |
|
|
43
|
+
|
|
44
|
+
### JS API
|
|
45
|
+
|
|
46
|
+
Access the utility instance directly via the `lnAutoresize` property on the textarea element:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const textarea = document.getElementById('my-textarea');
|
|
50
|
+
|
|
51
|
+
// 1. Force a manual re-measure (essential after parent reveals, font loads, etc.)
|
|
52
|
+
textarea.lnAutoresize._resize();
|
|
53
|
+
|
|
54
|
+
// 2. Tear down event listeners and clear inline styles
|
|
55
|
+
textarea.lnAutoresize.destroy();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## ⚡ DOM Events
|
|
61
|
+
|
|
62
|
+
`ln-autoresize` does not emit custom events. It relies entirely on standard browser interactions:
|
|
63
|
+
|
|
64
|
+
- **Listens to `input`:** Triggers `_resize()` on keystrokes, pastes, and deletes.
|
|
65
|
+
- **Listens to `change`:** Triggers `_resize()` on value commits.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## ⚠️ Common Pitfalls
|
|
70
|
+
|
|
71
|
+
- **Setting `textarea.value` directly:** Programmatic writes are silent in the DOM. The component will not detect value updates unless you dispatch a synthetic event:
|
|
72
|
+
```javascript
|
|
73
|
+
textarea.value = 'New text content';
|
|
74
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
75
|
+
```
|
|
76
|
+
- **Mounting in hidden parents:** A textarea inside a `display: none` container measures `scrollHeight` as `0`. When revealed later, it will appear collapsed. Force a re-measure manually after reveal:
|
|
77
|
+
```javascript
|
|
78
|
+
textarea.lnAutoresize._resize();
|
|
79
|
+
```
|
|
80
|
+
- **Conflicting manual drag handles:** By default, textareas may have `resize: vertical` applied. This lets users manual resize the input, which is instantly overridden by the script on the next keystroke. Set `resize: none` to clean up the interface.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function y(e,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){y(e,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}e()}function h(e,t,n,r){if(e.nodeType!==1)return;const f=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",a=Array.from(e.querySelectorAll(f));e.matches&&e.matches(f)&&a.push(e);for(const i of a)i[n]||(i[n]=new r(i))}function b(e,t,n,r,s={}){const f=s.extraAttributes||[],a=s.onAttributeChange||null,i=s.onInit||null;function c(p){const d=p||document.body;h(d,e,t,n),i&&i(d)}return y(function(){const p=new MutationObserver(function(l){for(let u=0;u<l.length;u++){const o=l[u];if(o.type==="childList")for(let g=0;g<o.addedNodes.length;g++){const m=o.addedNodes[g];m.nodeType===1&&(h(m,e,t,n),i&&i(m))}else o.type==="attributes"&&(a&&o.target[t]?a(o.target,o.attributeName):(h(o.target,e,t,n),i&&i(o.target)))}});let d=[];if(e.indexOf("[")!==-1){const l=/\[([\w-]+)/g;let u;for(;(u=l.exec(e))!==null;)d.push(u[1])}else d.push(e);p.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:d.concat(f)})},r),window[t]=c,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){c(document.body)}):c(document.body),c}const w={};function x(e,t){w[e]=t}function A(e){return w[e]||{ingress:t=>t,egress:t=>t}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=x,window.lnCore.getDataMapper=A),(function(){const e="data-ln-autoresize",t="lnAutoresize";if(window[t]!==void 0)return;function n(r){if(r.tagName!=="TEXTAREA")return console.warn("[ln-autoresize] Can only be applied to <textarea>, got:",r.tagName),this;this.dom=r;const s=this;return this._onInput=function(){s._resize()},r.addEventListener("input",this._onInput),this._resize(),this}n.prototype._resize=function(){this.dom.style.height="auto",this.dom.style.height=this.dom.scrollHeight+"px"},n.prototype.destroy=function(){this.dom[t]&&(this.dom.removeEventListener("input",this._onInput),this.dom.style.height="",delete this.dom[t])},b(e,t,n,"ln-autoresize")})()})();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { registerComponent } from '../../ln-core';
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const DOM_SELECTOR = 'data-ln-autoresize';
|
|
5
|
+
const DOM_ATTRIBUTE = 'lnAutoresize';
|
|
6
|
+
|
|
7
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
8
|
+
|
|
9
|
+
// ─── Component ─────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function _component(dom) {
|
|
12
|
+
if (dom.tagName !== 'TEXTAREA') {
|
|
13
|
+
console.warn('[ln-autoresize] Can only be applied to <textarea>, got:', dom.tagName);
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.dom = dom;
|
|
18
|
+
|
|
19
|
+
const self = this;
|
|
20
|
+
this._onInput = function () {
|
|
21
|
+
self._resize();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
dom.addEventListener('input', this._onInput);
|
|
25
|
+
|
|
26
|
+
// Initial resize (content may be pre-filled)
|
|
27
|
+
this._resize();
|
|
28
|
+
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_component.prototype._resize = function () {
|
|
33
|
+
this.dom.style.height = 'auto';
|
|
34
|
+
this.dom.style.height = this.dom.scrollHeight + 'px';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
_component.prototype.destroy = function () {
|
|
38
|
+
if (!this.dom[DOM_ATTRIBUTE]) return;
|
|
39
|
+
this.dom.removeEventListener('input', this._onInput);
|
|
40
|
+
this.dom.style.height = '';
|
|
41
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ─── Init ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-autoresize');
|
|
47
|
+
})();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ln-autosave
|
|
2
|
+
|
|
3
|
+
A zero-dependency, localStorage-backed **Draft Buffer Primitive** that prevents data loss by capturing form states. It automatically saves drafts on field boundaries, restores values on load, and clears them cleanly on submit, reset, or custom cancellations.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🧭 Philosophy & Architecture
|
|
8
|
+
|
|
9
|
+
1. **Synchronous local storage:** Unlike async network databases, `ln-autosave` uses browser `localStorage`. This ensures drafts are written instantly on field blur/leave (`focusout` / `change`), eliminating race conditions during page changes.
|
|
10
|
+
2. **Zero-Keystroke flood:** By default, writing only triggers on field blur. For continuous typing fields (like editors), an opt-in debounced input listener can be configured.
|
|
11
|
+
3. **Decoupled Event Restoration:** restored values are applied by dispatching standard synthetic `input` and `change` events. This ensures sibling primitives (such as `ln-validate` and `ln-autoresize`) re-evaluate automatically.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 📦 Minimal Blueprint
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<form id="profile-edit" data-ln-autosave>
|
|
19
|
+
<div class="form-element">
|
|
20
|
+
<label for="bio">Biography</label>
|
|
21
|
+
<textarea id="bio" name="bio" data-ln-autoresize rows="1"></textarea>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<ul class="form-actions">
|
|
25
|
+
<li><button type="button" data-ln-autosave-clear>Cancel (clear draft)</button></li>
|
|
26
|
+
<li><button type="submit">Save</button></li>
|
|
27
|
+
</ul>
|
|
28
|
+
</form>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> [!IMPORTANT]
|
|
32
|
+
> The form **must** have a unique `id` or you must specify a custom identifier in `data-ln-autosave="identifier"`. The final storage key is scoped uniquely as `ln-autosave:{pathname}:{identifier}`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 🛠️ Declarative API Contract
|
|
37
|
+
|
|
38
|
+
### HTML Attributes
|
|
39
|
+
|
|
40
|
+
| Attribute | Elements | Description |
|
|
41
|
+
| :--- | :--- | :--- |
|
|
42
|
+
| `data-ln-autosave` | `<form>` | Persistence marker. Value overrides the form ID lookup. |
|
|
43
|
+
| `data-ln-autosave-clear` | `<button>` | Click delegate. Instantly purges the localStorage entry. |
|
|
44
|
+
| `data-ln-autosave-debounce-input="ms"` | `<form>` | Opt-in. Saves on idle keystrokes (defaults to 1000ms if empty). |
|
|
45
|
+
|
|
46
|
+
### JS API
|
|
47
|
+
|
|
48
|
+
Access the persistence instance directly via the `lnAutoresize` property on the form element:
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
const form = document.getElementById('profile-edit');
|
|
52
|
+
|
|
53
|
+
// 1. Back-reference properties
|
|
54
|
+
const storageKey = form.lnAutosave.key; // "ln-autosave:/path:profile-edit"
|
|
55
|
+
|
|
56
|
+
// 2. Clean up listeners and pending timers
|
|
57
|
+
form.lnAutosave.destroy();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## ⚡ DOM Events
|
|
63
|
+
|
|
64
|
+
### Emitted
|
|
65
|
+
|
|
66
|
+
| Event | Bubbles | Payload | Description |
|
|
67
|
+
| :--- | :--- | :--- | :--- |
|
|
68
|
+
| `ln-autosave:before-restore` | Yes (Cancelable) | `{ target, data }` | Fires before applying draft. Call `e.preventDefault()` to abort. |
|
|
69
|
+
| `ln-autosave:restored` | Yes | `{ target, data }` | Dispatched after populated values and synthetic events are sent. |
|
|
70
|
+
| `ln-autosave:saved` | Yes | `{ target, data }` | Dispatched on successful localStorage save. |
|
|
71
|
+
| `ln-autosave:cleared` | Yes | `{ target }` | Dispatched after localStorage item removal. |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## ⚠️ Common Pitfalls
|
|
76
|
+
|
|
77
|
+
- **Stale Drafts Overwriting Server Data:** On pages where the database has rendered new state, draft restores must be aborted:
|
|
78
|
+
```javascript
|
|
79
|
+
document.addEventListener('ln-autosave:before-restore', function (e) {
|
|
80
|
+
if (e.target.dataset.hasServerData === 'true') {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
localStorage.removeItem(e.target.lnAutosave.key);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
- **ESC Modal Dismissals:** If a user closes a modal using the ESC key, no click triggers on cancel buttons. Wire draft cleanup to the modal's closed event manually:
|
|
87
|
+
```javascript
|
|
88
|
+
modal.addEventListener('ln-modal:closed', () => {
|
|
89
|
+
localStorage.removeItem(form.lnAutosave.key);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
- **Nameless & File Inputs:** `ln-autosave` ignores disabled inputs, button elements, `type="file"`, and inputs missing a `name` attribute.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";function E(o,e,u){o.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:u||{}}))}function A(o,e,u){const s=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:u||{}});return o.dispatchEvent(s),s}function C(o,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){C(o,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}o()}function y(o,e,u,s){if(o.nodeType!==1)return;const i=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",l=Array.from(o.querySelectorAll(i));o.matches&&o.matches(i)&&l.push(o);for(const c of l)c[u]||(c[u]=new s(c))}function O(o){const e={},u=o.elements;for(let s=0;s<u.length;s++){const n=u[s];if(!(!n.name||n.disabled||n.type==="file"||n.type==="submit"||n.type==="button"))if(n.type==="checkbox")e[n.name]||(e[n.name]=[]),n.checked&&e[n.name].push(n.value);else if(n.type==="radio")n.checked&&(e[n.name]=n.value);else if(n.type==="select-multiple"){e[n.name]=[];for(let i=0;i<n.options.length;i++)n.options[i].selected&&e[n.name].push(n.options[i].value)}else e[n.name]=n.value}return e}function S(o,e){const u=o.elements,s=[];for(let n=0;n<u.length;n++){const i=u[n];if(!i.name||!(i.name in e)||i.type==="file"||i.type==="submit"||i.type==="button")continue;const l=e[i.name];if(i.type==="checkbox")i.checked=Array.isArray(l)?l.indexOf(i.value)!==-1:!!l,s.push(i);else if(i.type==="radio")i.checked=i.value===String(l),s.push(i);else if(i.type==="select-multiple"){if(Array.isArray(l))for(let c=0;c<i.options.length;c++)i.options[c].selected=l.indexOf(i.options[c].value)!==-1;s.push(i)}else i.value=l,s.push(i)}return s}function T(o,e,u,s,n={}){const i=n.extraAttributes||[],l=n.onAttributeChange||null,c=n.onInit||null;function p(b){const t=b||document.body;y(t,o,e,u),c&&c(t)}return C(function(){const b=new MutationObserver(function(a){for(let r=0;r<a.length;r++){const h=a[r];if(h.type==="childList")for(let g=0;g<h.addedNodes.length;g++){const v=h.addedNodes[g];v.nodeType===1&&(y(v,o,e,u),c&&c(v))}else h.type==="attributes"&&(l&&h.target[e]?l(h.target,h.attributeName):(y(h.target,o,e,u),c&&c(h.target)))}});let t=[];if(o.indexOf("[")!==-1){const a=/\[([\w-]+)/g;let r;for(;(r=a.exec(o))!==null;)t.push(r[1])}else t.push(o);b.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:t.concat(i)})},s),window[e]=p,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){p(document.body)}):p(document.body),p}const w={};function D(o,e){w[o]=e}function I(o){return w[o]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=D,window.lnCore.getDataMapper=I),(function(){const o="data-ln-autosave",e="lnAutosave",u="data-ln-autosave-clear",s="data-ln-autosave-debounce-input",n="ln-autosave:";if(window[e]!==void 0)return;function l(t){const a=c(t);if(!a){console.warn("ln-autosave: form needs an id or data-ln-autosave value",t);return}this.dom=t,this.key=a;let r=null;function h(){const d=O(t);try{localStorage.setItem(a,JSON.stringify(d))}catch{return}E(t,"ln-autosave:saved",{target:t,data:d})}function g(){let d;try{d=localStorage.getItem(a)}catch{return}if(!d)return;let f;try{f=JSON.parse(d)}catch{return}if(A(t,"ln-autosave:before-restore",{target:t,data:f}).defaultPrevented)return;const _=S(t,f);for(let m=0;m<_.length;m++)_[m].dispatchEvent(new Event("input",{bubbles:!0})),_[m].dispatchEvent(new Event("change",{bubbles:!0}));E(t,"ln-autosave:restored",{target:t,data:f})}function v(){try{localStorage.removeItem(a)}catch{return}E(t,"ln-autosave:cleared",{target:t})}this._onFocusout=function(d){const f=d.target;p(f)&&f.name&&h()},this._onChange=function(d){const f=d.target;p(f)&&f.name&&h()},this._onSubmit=function(){v()},this._onReset=function(){v()},this._onClearClick=function(d){d.target.closest("["+u+"]")&&v()},t.addEventListener("focusout",this._onFocusout),t.addEventListener("change",this._onChange),t.addEventListener("submit",this._onSubmit),t.addEventListener("reset",this._onReset),t.addEventListener("click",this._onClearClick);const L=b(t);return L>0&&(this._onInput=function(d){const f=d.target;!p(f)||!f.name||(r!==null&&clearTimeout(r),r=setTimeout(h,L))},t.addEventListener("input",this._onInput)),this._getInputTimer=function(){return r},g(),this}l.prototype.destroy=function(){if(this.dom[e]){if(this.dom.removeEventListener("focusout",this._onFocusout),this.dom.removeEventListener("change",this._onChange),this.dom.removeEventListener("submit",this._onSubmit),this.dom.removeEventListener("reset",this._onReset),this.dom.removeEventListener("click",this._onClearClick),this._onInput){this.dom.removeEventListener("input",this._onInput);const t=this._getInputTimer();t!==null&&clearTimeout(t)}E(this.dom,"ln-autosave:destroyed",{target:this.dom}),delete this.dom[e]}};function c(t){const r=t.getAttribute(o)||t.id;return r?n+window.location.pathname+":"+r:null}function p(t){const a=t.tagName;return a==="INPUT"||a==="TEXTAREA"||a==="SELECT"}function b(t){if(!t.hasAttribute(s))return 0;const a=t.getAttribute(s);if(a===""||a===null)return 1e3;const r=parseInt(a,10);return isNaN(r)||r<0?(console.warn("ln-autosave: invalid debounce value, using default",t),1e3):r}T(o,e,l,"ln-autosave")})()})();
|