@klodd/ds 1.1.0 → 3.0.0

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.
Files changed (43) hide show
  1. package/css/00-primitives.css +62 -0
  2. package/css/10-semantic.css +81 -20
  3. package/css/apps/ekonom.css +9 -0
  4. package/css/base/layout.css +45 -0
  5. package/css/components/auth.css +28 -0
  6. package/css/components/avatar.css +31 -0
  7. package/css/components/badge.css +2 -2
  8. package/css/components/banner.css +63 -0
  9. package/css/components/chip.css +257 -0
  10. package/css/components/collapsible.css +64 -0
  11. package/css/components/divider.css +25 -0
  12. package/css/components/dropdown.css +88 -0
  13. package/css/components/feedback.css +6 -0
  14. package/css/components/form.css +67 -0
  15. package/css/components/hbar.css +64 -0
  16. package/css/components/hero.css +85 -0
  17. package/css/components/hub-card.css +100 -0
  18. package/css/components/inline-edit.css +67 -0
  19. package/css/components/input.css +1 -1
  20. package/css/components/list-row.css +115 -0
  21. package/css/components/overlay.css +3 -0
  22. package/css/components/panel.css +95 -0
  23. package/css/components/progress.css +39 -0
  24. package/css/components/setting-row.css +186 -0
  25. package/css/components/split-bar.css +53 -0
  26. package/css/components/stat.css +55 -0
  27. package/css/components/swipe-stack.css +200 -0
  28. package/css/components/tab-bar.css +58 -0
  29. package/css/components/table.css +39 -0
  30. package/css/components/tooltip.css +50 -0
  31. package/css/components/upload-spinner.css +55 -0
  32. package/css/index.css +32 -0
  33. package/css/utilities.css +81 -0
  34. package/js/bar-styles.js +37 -0
  35. package/js/hero-roll.js +287 -0
  36. package/js/index.js +95 -0
  37. package/js/lucide-init.js +27 -0
  38. package/js/nav-optimistic.js +74 -0
  39. package/js/pull-to-refresh.js +162 -0
  40. package/js/pwa-register.js +132 -0
  41. package/js/sheet-drag.js +163 -0
  42. package/js/turbo-nav.js +353 -0
  43. package/package.json +4 -3
@@ -0,0 +1,27 @@
1
+ /* Lucide-init: scannar DOM efter <i data-lucide="..."> och ersatter
2
+ * med <svg>-element. Kors vid DOMContentLoaded plus efter Turbo-nav-
3
+ * swap (om Turbo finns) sa nya element pa nyladdade pages aktiveras.
4
+ *
5
+ * Lucide CDN-script laddas separat via cdn.jsdelivr.net (allowed i
6
+ * Jubbs CSP). Detta init-script ar lokalt sa det forklaras inte av
7
+ * inline-script-restrict.
8
+ */
9
+ (function () {
10
+ 'use strict';
11
+
12
+ function createIcons() {
13
+ if (typeof window.lucide !== 'undefined' && window.lucide.createIcons) {
14
+ window.lucide.createIcons();
15
+ }
16
+ }
17
+
18
+ if (document.readyState === 'loading') {
19
+ document.addEventListener('DOMContentLoaded', createIcons);
20
+ } else {
21
+ createIcons();
22
+ }
23
+
24
+ // Re-init efter Turbo-swap sa nya ikoner pa swapped DOM aktiveras.
25
+ document.addEventListener('turbo:swap', createIcons);
26
+ document.addEventListener('turbo:navigated', createIcons);
27
+ })();
@@ -0,0 +1,74 @@
1
+ /*--------------------------------------------------------------
2
+ Sprint F1 + G4 + I: optimistic active-flytt pa bottom-nav.
3
+
4
+ Problem (CE 2026-04-27): tap pa bottom-nav-pod tar ~1s innan
5
+ podden blir visuellt aktiv. Anvandaren ser inaktiv pod tills
6
+ hela nya sidan laddats. Native-appar uppdaterar tab-bar
7
+ omedelbart vid tap, oberoende av content-render.
8
+
9
+ Losning: vid click pa nav-pod flytta active-class direkt till
10
+ tappad pod. Turbo-fetch (Sprint I) eller cross-document VT
11
+ renderar content parallellt. Failsafe rullar tillbaka active-
12
+ flytt om navigation inte landar (notification, network-stall
13
+ etc.).
14
+
15
+ Sprint G4: borttagen body.navigating-class + content-fade. Native
16
+ iOS fadar inte content under nav-byten. Active-flytt ensam ger
17
+ onskad feedback.
18
+
19
+ Sprint I: lyssna pa turbo:navigated istallet for pageshow
20
+ (pageshow firar inte vid same-document swap). Pageshow-handler
21
+ behalls for bfcache-restore.
22
+ --------------------------------------------------------------*/
23
+ ( function () {
24
+ const nav = document.querySelector( '.bottom-nav' );
25
+ if ( ! nav ) return;
26
+
27
+ let pendingNav = null;
28
+ let failsafeTimer = null;
29
+
30
+ function reset () {
31
+ pendingNav = null;
32
+ clearTimeout( failsafeTimer );
33
+ failsafeTimer = null;
34
+ }
35
+
36
+ nav.addEventListener( 'click', ( e ) => {
37
+ // Skippa modifier-clicks (cmd/ctrl/shift -> ny flik) och middle-click.
38
+ // Browser hanterar dem som "open in new tab" - vi vill INTE flytta active.
39
+ if ( e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) return;
40
+ if ( e.button !== undefined && e.button !== 0 ) return;
41
+
42
+ const pod = e.target.closest( '.nav-pod' );
43
+ if ( ! pod || ! nav.contains( pod ) ) return;
44
+ if ( pod.classList.contains( 'active' ) ) return;
45
+
46
+ const previousActive = nav.querySelector( '.nav-pod.active' );
47
+ previousActive && previousActive.classList.remove( 'active' );
48
+ pod.classList.add( 'active' );
49
+
50
+ pendingNav = { from: previousActive, to: pod };
51
+
52
+ // Failsafe: om ny sida inte landar inom 3s, rulla tillbaka. Skyddar
53
+ // mot edge-cases dar navigation avbryts (notification dragdown,
54
+ // network-stall, abort etc.).
55
+ clearTimeout( failsafeTimer );
56
+ failsafeTimer = setTimeout( () => {
57
+ if ( pendingNav ) {
58
+ pendingNav.to.classList.remove( 'active' );
59
+ pendingNav.from && pendingNav.from.classList.add( 'active' );
60
+ reset();
61
+ }
62
+ }, 3000 );
63
+ } );
64
+
65
+ // Sprint I: turbo-swap rensar pending state och korrigerar active-class
66
+ // om server-render skiljer sig fran optimistic flytt (turbo-nav.js gor
67
+ // performSwap.bottom-nav-active-justering vilket overskuggar var optimistic
68
+ // flytt om server-render landade pa annan route).
69
+ document.addEventListener( 'turbo:navigated', reset );
70
+
71
+ // pageshow firar bade vid fresh load OCH bfcache-restore. Behalls for
72
+ // fallback-fall (no-turbo, full reload, browser back utan turbo-state).
73
+ window.addEventListener( 'pageshow', reset );
74
+ } )();
@@ -0,0 +1,162 @@
1
+ /*--------------------------------------------------------------
2
+ Ekonom - Pull-to-refresh (Sprint E1).
3
+
4
+ Pointer Events-baserad pull fran top-of-page nar window.scrollY === 0.
5
+ Aktiveras pa main routes (avstamning/kategorier/bolanet/jag) via
6
+ data-ptr-enabled-attribut pa body.
7
+
8
+ Fysik:
9
+ - 1:1 tracking forsta 40px, sen kvadratisk dampning
10
+ - Threshold: 80px med damping for trigger
11
+ - Spinner-indikator visas centrerad ovanfor hero
12
+ - Refresh = location.reload()
13
+
14
+ Edge cases (no-op):
15
+ - Sheet ar oppen
16
+ - Form har :focus-within
17
+ - VT-animation pagar (impossible to detect reliably; vi kollar via
18
+ document.startViewTransition existence + nyligen-startad-flagga)
19
+ - Aktiv upload (.upload-spinner-overlay.visible)
20
+ --------------------------------------------------------------*/
21
+ (function () {
22
+ 'use strict';
23
+
24
+ const THRESHOLD_PX = 80;
25
+ const LINEAR_PX = 40;
26
+ const SETTLE_PX = 60;
27
+ const REFRESH_DELAY_MS = 200;
28
+
29
+ function shouldEnable() {
30
+ return document.body.dataset.ptrEnabled === 'true' ||
31
+ document.body.hasAttribute('data-ptr-enabled');
32
+ }
33
+
34
+ function isBlocked() {
35
+ // Sheet/dialog open
36
+ if (document.querySelector('dialog.sheet[open]')) return true;
37
+ // Form has focus (keyboard up)
38
+ if (document.activeElement && document.activeElement.matches &&
39
+ (document.activeElement.matches('input, textarea, select') ||
40
+ document.activeElement.closest('form:focus-within'))) return true;
41
+ // Upload spinner active
42
+ const upload = document.getElementById('upload-spinner-overlay');
43
+ if (upload && upload.classList.contains('visible')) return true;
44
+ return false;
45
+ }
46
+
47
+ function applyResistance(dy) {
48
+ if (dy <= LINEAR_PX) return dy;
49
+ // kvadratisk dampning efter 40px
50
+ return LINEAR_PX + Math.sqrt(dy - LINEAR_PX) * 4;
51
+ }
52
+
53
+ function ensureIndicator() {
54
+ let el = document.getElementById('ptr-indicator');
55
+ if (el) return el;
56
+ el = document.createElement('div');
57
+ el.id = 'ptr-indicator';
58
+ el.className = 'ptr-indicator';
59
+ el.setAttribute('aria-hidden', 'true');
60
+ el.innerHTML = '<div class="ptr-spinner"></div>';
61
+ document.body.appendChild(el);
62
+ return el;
63
+ }
64
+
65
+ let dragging = false;
66
+ let startY = 0;
67
+ let pullY = 0;
68
+ let pointerId = null;
69
+ let triggered = false;
70
+
71
+ function onPointerDown(e) {
72
+ if (!shouldEnable()) return;
73
+ if (isBlocked()) return;
74
+ if (window.scrollY > 0) return;
75
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
76
+ dragging = true;
77
+ triggered = false;
78
+ startY = e.clientY;
79
+ pullY = 0;
80
+ pointerId = e.pointerId;
81
+ }
82
+
83
+ function onPointerMove(e) {
84
+ if (!dragging || e.pointerId !== pointerId) return;
85
+ const dy = e.clientY - startY;
86
+ if (dy <= 0) {
87
+ pullY = 0;
88
+ updateIndicator(0);
89
+ return;
90
+ }
91
+ // Om scrollY har gatt > 0 sen drag-start (t.ex. anvandaren bytte riktning),
92
+ // avbryt drag.
93
+ if (window.scrollY > 0) {
94
+ cancelDrag();
95
+ return;
96
+ }
97
+ pullY = applyResistance(dy);
98
+ updateIndicator(pullY);
99
+ // Forhindra default sa browsern inte scrollar (om vi ar pa scrollY=0).
100
+ if (e.cancelable) e.preventDefault();
101
+ }
102
+
103
+ function onPointerUp(e) {
104
+ if (!dragging) return;
105
+ dragging = false;
106
+ const indicator = document.getElementById('ptr-indicator');
107
+ if (pullY >= THRESHOLD_PX && !triggered) {
108
+ triggered = true;
109
+ // Snap till SETTLE_PX, sedan reload.
110
+ if (indicator) {
111
+ indicator.style.transition = 'transform 220ms var(--ease-spring-snappy)';
112
+ indicator.style.transform = 'translate(-50%, ' + SETTLE_PX + 'px)';
113
+ indicator.classList.add('ptr-indicator--triggered');
114
+ }
115
+ setTimeout(function () { window.location.reload(); }, REFRESH_DELAY_MS);
116
+ } else {
117
+ // Spring-back till 0.
118
+ if (indicator) {
119
+ indicator.style.transition = 'transform 320ms var(--ease-spring-bounce), opacity 220ms';
120
+ indicator.style.transform = 'translate(-50%, -40px)';
121
+ indicator.style.opacity = '0';
122
+ const onEnd = function () {
123
+ indicator.removeEventListener('transitionend', onEnd);
124
+ indicator.style.transition = '';
125
+ indicator.style.transform = '';
126
+ indicator.style.opacity = '';
127
+ };
128
+ indicator.addEventListener('transitionend', onEnd, { once: true });
129
+ }
130
+ }
131
+ pullY = 0;
132
+ }
133
+
134
+ function cancelDrag() {
135
+ dragging = false;
136
+ const indicator = document.getElementById('ptr-indicator');
137
+ if (indicator) {
138
+ indicator.style.transition = 'transform 220ms, opacity 180ms';
139
+ indicator.style.transform = 'translate(-50%, -40px)';
140
+ indicator.style.opacity = '0';
141
+ }
142
+ pullY = 0;
143
+ }
144
+
145
+ function updateIndicator(y) {
146
+ const el = ensureIndicator();
147
+ el.style.transition = 'none';
148
+ // Translate fran -40px (dolt ovanfor viewport) till y px nedat.
149
+ const translateY = -40 + y;
150
+ el.style.transform = 'translate(-50%, ' + translateY + 'px)';
151
+ const progress = Math.min(1, y / THRESHOLD_PX);
152
+ el.style.opacity = String(progress);
153
+ // Rotation pa spinnern: hela 360deg vid threshold.
154
+ const spinner = el.firstChild;
155
+ if (spinner) spinner.style.transform = 'rotate(' + (progress * 360) + 'deg)';
156
+ }
157
+
158
+ document.addEventListener('pointerdown', onPointerDown, { passive: true });
159
+ document.addEventListener('pointermove', onPointerMove, { passive: false });
160
+ document.addEventListener('pointerup', onPointerUp, { passive: true });
161
+ document.addEventListener('pointercancel', cancelDrag, { passive: true });
162
+ })();
@@ -0,0 +1,132 @@
1
+ /*--------------------------------------------------------------
2
+ Ekonom PWA register
3
+ - isNetworkError() - iOS-saker nat-detektering
4
+ - Service worker registrering med updateViaCache: 'none'
5
+ - Install-prompt (beforeinstallprompt for Chrome/Android)
6
+ - iOS install-hint (Safari har ingen beforeinstallprompt)
7
+ --------------------------------------------------------------*/
8
+
9
+ /*--------------------------------------------------------------
10
+ Natverksfel-detektering
11
+ navigator.onLine ljuger pa iOS Safari. Triangulera genom att ocksa
12
+ kolla errortyp och message-text. Returnerar true om ett fetch-fel
13
+ troligen beror pa natverket (= OK att visa offline-meddelande).
14
+ --------------------------------------------------------------*/
15
+ window.isNetworkError = function ( err ) {
16
+ if ( ! navigator.onLine ) return true;
17
+ if ( err instanceof TypeError ) return true;
18
+ if ( err && err.message ) {
19
+ return /network|failed to fetch|load failed|offline|cancelled/i.test( err.message );
20
+ }
21
+ return false;
22
+ };
23
+
24
+ /*--------------------------------------------------------------
25
+ SW-registrering
26
+ --------------------------------------------------------------*/
27
+ if ( 'serviceWorker' in navigator ) {
28
+ window.addEventListener( 'load', async function () {
29
+ try {
30
+ const reg = await navigator.serviceWorker.register( '/sw.js', {
31
+ scope: '/',
32
+ updateViaCache: 'none',
33
+ } );
34
+ console.log( 'Ekonom SW registered. Scope:', reg.scope );
35
+ } catch ( err ) {
36
+ console.warn( 'Ekonom SW registration failed:', err );
37
+ }
38
+ } );
39
+ }
40
+
41
+ /*--------------------------------------------------------------
42
+ Install-prompt (Android / Chrome / Edge)
43
+ --------------------------------------------------------------*/
44
+ let deferredInstallPrompt = null;
45
+
46
+ window.addEventListener( 'beforeinstallprompt', function ( e ) {
47
+ e.preventDefault();
48
+ deferredInstallPrompt = e;
49
+ maybeShowInstallChip();
50
+ } );
51
+
52
+ function maybeShowInstallChip() {
53
+ // Inte om redan dismissat denna session
54
+ if ( sessionStorage.getItem( 'ekonom_install_dismissed' ) ) return;
55
+ // Inte om redan installerad
56
+ if ( window.matchMedia( '(display-mode: standalone)' ).matches ) return;
57
+
58
+ // Bara efter 3+ besok
59
+ const visits = parseInt( localStorage.getItem( 'ekonom_visits' ) || '0', 10 ) + 1;
60
+ localStorage.setItem( 'ekonom_visits', String( visits ) );
61
+ if ( visits < 3 ) return;
62
+
63
+ showInstallChip();
64
+ }
65
+
66
+ function showInstallChip() {
67
+ if ( document.getElementById( 'ekonom-install-chip' ) ) return;
68
+
69
+ const chip = document.createElement( 'div' );
70
+ chip.className = 'install-chip';
71
+ chip.id = 'ekonom-install-chip';
72
+ chip.setAttribute( 'role', 'dialog' );
73
+ chip.setAttribute( 'aria-label', 'Installera Ekonom' );
74
+ chip.innerHTML =
75
+ '<span>Installera Ekonom p\u00e5 hemsk\u00e4rmen</span>' +
76
+ '<button type="button" data-install>Installera</button>' +
77
+ '<button type="button" data-dismiss aria-label="St\u00e4ng">\u00D7</button>';
78
+ document.body.appendChild( chip );
79
+
80
+ chip.querySelector( '[data-install]' ).addEventListener( 'click', async function () {
81
+ if ( ! deferredInstallPrompt ) { chip.remove(); return; }
82
+ deferredInstallPrompt.prompt();
83
+ try { await deferredInstallPrompt.userChoice; } catch ( e ) {}
84
+ deferredInstallPrompt = null;
85
+ chip.remove();
86
+ } );
87
+ chip.querySelector( '[data-dismiss]' ).addEventListener( 'click', function () {
88
+ sessionStorage.setItem( 'ekonom_install_dismissed', '1' );
89
+ chip.remove();
90
+ } );
91
+ }
92
+
93
+ /*--------------------------------------------------------------
94
+ iOS install-hint
95
+ Safari har ingen beforeinstallprompt - anvandaren maste manuellt
96
+ "Dela" -> "Lagg till pa hemskarmen". Visa en diskret chip efter
97
+ 3+ besok sa de vet var de ska titta.
98
+ --------------------------------------------------------------*/
99
+ window.addEventListener( 'load', function () {
100
+ const isIOS = /iPad|iPhone|iPod/.test( navigator.userAgent ) && ! window.MSStream;
101
+ const inStandalone = window.matchMedia( '(display-mode: standalone)' ).matches
102
+ || window.navigator.standalone;
103
+
104
+ if ( ! isIOS || inStandalone ) return;
105
+
106
+ if ( sessionStorage.getItem( 'ekonom_ios_install_dismissed' ) ) return;
107
+
108
+ // Anvand samma besok-rakning som Android-chip:en.
109
+ const visits = parseInt( localStorage.getItem( 'ekonom_visits' ) || '0', 10 );
110
+ if ( visits < 3 ) return;
111
+
112
+ showIOSInstallHint();
113
+ } );
114
+
115
+ function showIOSInstallHint() {
116
+ if ( document.getElementById( 'ekonom-ios-install-hint' ) ) return;
117
+
118
+ const hint = document.createElement( 'div' );
119
+ hint.className = 'install-chip install-chip-ios';
120
+ hint.id = 'ekonom-ios-install-hint';
121
+ hint.setAttribute( 'role', 'dialog' );
122
+ hint.setAttribute( 'aria-label', 'Installera Ekonom pa iOS' );
123
+ hint.innerHTML =
124
+ '<span>Tryck <strong>Dela</strong> \u2192 <strong>L\u00e4gg till p\u00e5 hemsk\u00e4rmen</strong></span>' +
125
+ '<button type="button" data-dismiss aria-label="St\u00e4ng">\u00D7</button>';
126
+ document.body.appendChild( hint );
127
+
128
+ hint.querySelector( '[data-dismiss]' ).addEventListener( 'click', function () {
129
+ sessionStorage.setItem( 'ekonom_ios_install_dismissed', '1' );
130
+ hint.remove();
131
+ } );
132
+ }
@@ -0,0 +1,163 @@
1
+ /*--------------------------------------------------------------
2
+ Ekonom - Sheet drag-to-dismiss (Sprint C2).
3
+
4
+ Pointer Events-baserad drag fran sheet-handle (eller sheet-toppen
5
+ nar sheet ar scrollad till topp). 1:1 tracking under drag.
6
+ Threshold for dismiss: 40% av sheet-hojd ELLER velocity > 0.5 px/ms.
7
+ Spring-back om under threshold (CSS bounce-easing).
8
+ Backdrop-opacitet foljer drag-progress.
9
+
10
+ Aktiveras automatiskt pa alla <dialog class="sheet"> via MutationObserver
11
+ (sheets som rendreras dynamiskt fangas in).
12
+ --------------------------------------------------------------*/
13
+ (function () {
14
+ 'use strict';
15
+
16
+ const DRAG_THRESHOLD_RATIO = 0.4; // 40% av sheet-hojd
17
+ const VELOCITY_THRESHOLD = 0.5; // px/ms
18
+ const VELOCITY_WINDOW_MS = 100; // sliding window
19
+ const ACTIVE_ATTR = 'data-drag-active';
20
+
21
+ function attach(dialog) {
22
+ if (dialog.dataset.dragAttached) return;
23
+ dialog.dataset.dragAttached = '1';
24
+
25
+ const handle = dialog.querySelector('.sheet-handle');
26
+ if (!handle) return;
27
+
28
+ let dragging = false;
29
+ let startY = 0;
30
+ let currentY = 0;
31
+ let height = 0;
32
+ let pointerId = null;
33
+ const samples = []; // {t, y}
34
+
35
+ function pushSample(y) {
36
+ const now = performance.now();
37
+ samples.push({ t: now, y: y });
38
+ while (samples.length && now - samples[0].t > VELOCITY_WINDOW_MS) {
39
+ samples.shift();
40
+ }
41
+ }
42
+
43
+ function computeVelocity() {
44
+ if (samples.length < 2) return 0;
45
+ const first = samples[0];
46
+ const last = samples[samples.length - 1];
47
+ const dt = last.t - first.t;
48
+ if (dt <= 0) return 0;
49
+ return (last.y - first.y) / dt; // px/ms
50
+ }
51
+
52
+ function setBackdropOpacity(progress) {
53
+ // progress 0..1: 0 = fully visible backdrop, 1 = fully faded.
54
+ // Variabeln satts pa documentElement eftersom ::backdrop inte arvs
55
+ // fran dialog-elementet (separat pseudo-element-tree).
56
+ document.documentElement.style.setProperty('--drag-progress', progress.toFixed(3));
57
+ }
58
+
59
+ function onStart(e) {
60
+ // Bara primary pointer.
61
+ if (e.button !== undefined && e.button !== 0) return;
62
+ // Ignorera om sheet inte ar oppen.
63
+ if (!dialog.open) return;
64
+ dragging = true;
65
+ pointerId = e.pointerId;
66
+ startY = e.clientY;
67
+ currentY = 0;
68
+ height = dialog.offsetHeight;
69
+ samples.length = 0;
70
+ pushSample(0);
71
+ dialog.style.transition = 'none';
72
+ dialog.setAttribute(ACTIVE_ATTR, '');
73
+ try { handle.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
74
+ e.preventDefault();
75
+ }
76
+
77
+ function onMove(e) {
78
+ if (!dragging || e.pointerId !== pointerId) return;
79
+ const dy = e.clientY - startY;
80
+ if (dy < 0) {
81
+ // Bara nedat (positive dy). Negativ = clamp till 0.
82
+ currentY = 0;
83
+ dialog.style.transform = 'translateY(0)';
84
+ pushSample(0);
85
+ setBackdropOpacity(0);
86
+ return;
87
+ }
88
+ currentY = dy;
89
+ dialog.style.transform = 'translateY(' + dy + 'px)';
90
+ pushSample(dy);
91
+ setBackdropOpacity(Math.min(1, dy / height));
92
+ }
93
+
94
+ function onEnd(e) {
95
+ if (!dragging) return;
96
+ dragging = false;
97
+ const velocity = computeVelocity();
98
+ const threshold = height * DRAG_THRESHOLD_RATIO;
99
+ try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
100
+ dialog.removeAttribute(ACTIVE_ATTR);
101
+
102
+ if (currentY > threshold || velocity > VELOCITY_THRESHOLD) {
103
+ // Dismiss med inertia. Duration skalar med velocity (snabbare snart -> kortare anim).
104
+ const remaining = height - currentY;
105
+ const baseDuration = 240;
106
+ const adjusted = velocity > VELOCITY_THRESHOLD
107
+ ? Math.max(120, baseDuration - velocity * 80)
108
+ : baseDuration;
109
+ dialog.style.transition = 'transform ' + adjusted + 'ms var(--ease-spring-snappy)';
110
+ dialog.style.transform = 'translateY(100%)';
111
+ const onTransitionEnd = function () {
112
+ dialog.removeEventListener('transitionend', onTransitionEnd);
113
+ // Reset state innan close sa nasta open-renderar fran 0.
114
+ dialog.style.transition = '';
115
+ dialog.style.transform = '';
116
+ setBackdropOpacity(0);
117
+ if (typeof dialog.close === 'function') dialog.close();
118
+ };
119
+ dialog.addEventListener('transitionend', onTransitionEnd, { once: true });
120
+ } else {
121
+ // Snap-back med spring-bounce.
122
+ dialog.style.transition = 'transform 320ms var(--ease-spring-bounce)';
123
+ dialog.style.transform = 'translateY(0)';
124
+ setBackdropOpacity(0);
125
+ const onTransitionEnd = function () {
126
+ dialog.removeEventListener('transitionend', onTransitionEnd);
127
+ dialog.style.transition = '';
128
+ dialog.style.transform = '';
129
+ };
130
+ dialog.addEventListener('transitionend', onTransitionEnd, { once: true });
131
+ }
132
+ }
133
+
134
+ handle.addEventListener('pointerdown', onStart);
135
+ document.addEventListener('pointermove', onMove);
136
+ document.addEventListener('pointerup', onEnd);
137
+ document.addEventListener('pointercancel', onEnd);
138
+ }
139
+
140
+ function attachAll() {
141
+ document.querySelectorAll('dialog.sheet').forEach(attach);
142
+ }
143
+
144
+ if (document.readyState === 'loading') {
145
+ document.addEventListener('DOMContentLoaded', attachAll);
146
+ } else {
147
+ attachAll();
148
+ }
149
+
150
+ // Fanga sheets som tillkommer dynamiskt (osannolikt i Ekonom men robust).
151
+ const observer = new MutationObserver(function (mutations) {
152
+ for (const m of mutations) {
153
+ for (const node of m.addedNodes) {
154
+ if (node.nodeType !== 1) continue;
155
+ if (node.matches && node.matches('dialog.sheet')) attach(node);
156
+ if (node.querySelectorAll) {
157
+ node.querySelectorAll('dialog.sheet').forEach(attach);
158
+ }
159
+ }
160
+ }
161
+ });
162
+ observer.observe(document.body, { childList: true, subtree: true });
163
+ })();