@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.
- package/css/00-primitives.css +62 -0
- package/css/10-semantic.css +81 -20
- package/css/apps/ekonom.css +9 -0
- package/css/base/layout.css +45 -0
- package/css/components/auth.css +28 -0
- package/css/components/avatar.css +31 -0
- package/css/components/badge.css +2 -2
- package/css/components/banner.css +63 -0
- package/css/components/chip.css +257 -0
- package/css/components/collapsible.css +64 -0
- package/css/components/divider.css +25 -0
- package/css/components/dropdown.css +88 -0
- package/css/components/feedback.css +6 -0
- package/css/components/form.css +67 -0
- package/css/components/hbar.css +64 -0
- package/css/components/hero.css +85 -0
- package/css/components/hub-card.css +100 -0
- package/css/components/inline-edit.css +67 -0
- package/css/components/input.css +1 -1
- package/css/components/list-row.css +115 -0
- package/css/components/overlay.css +3 -0
- package/css/components/panel.css +95 -0
- package/css/components/progress.css +39 -0
- package/css/components/setting-row.css +186 -0
- package/css/components/split-bar.css +53 -0
- package/css/components/stat.css +55 -0
- package/css/components/swipe-stack.css +200 -0
- package/css/components/tab-bar.css +58 -0
- package/css/components/table.css +39 -0
- package/css/components/tooltip.css +50 -0
- package/css/components/upload-spinner.css +55 -0
- package/css/index.css +32 -0
- package/css/utilities.css +81 -0
- package/js/bar-styles.js +37 -0
- package/js/hero-roll.js +287 -0
- package/js/index.js +95 -0
- package/js/lucide-init.js +27 -0
- package/js/nav-optimistic.js +74 -0
- package/js/pull-to-refresh.js +162 -0
- package/js/pwa-register.js +132 -0
- package/js/sheet-drag.js +163 -0
- package/js/turbo-nav.js +353 -0
- 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
|
+
}
|
package/js/sheet-drag.js
ADDED
|
@@ -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
|
+
})();
|