@klodd/ds 1.2.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/base/layout.css +45 -0
- package/css/components/auth.css +28 -0
- package/css/components/avatar.css +31 -0
- package/css/components/banner.css +63 -0
- package/css/components/chip.css +257 -0
- package/css/components/collapsible.css +64 -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/list-row.css +115 -0
- package/css/components/panel.css +95 -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/upload-spinner.css +55 -0
- package/css/index.css +28 -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
package/js/hero-roll.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/* Hero Roll Animation - delad komponent
|
|
2
|
+
* Krav: .hero-amount[data-animate-roll] i HTML
|
|
3
|
+
* Krav: font-variant-numeric: tabular-nums pa elementet
|
|
4
|
+
* Krav: --lh-tight definierad i CSS (anvands som rullhojd)
|
|
5
|
+
* Turbo: triggar via turbo:swap-event om Turbo finns, annars fresh-session only
|
|
6
|
+
*
|
|
7
|
+
* Delad mellan Jubb och Ekonom (extraherad fran Ekonom 2026-05-07).
|
|
8
|
+
* Filen ar oforandrad fran Ekonom-originalet - portabel utan wrapper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/*--------------------------------------------------------------
|
|
12
|
+
Sprint J + J.4 + J.5: Hero-siffra rullnings-animation pa /avstamning.
|
|
13
|
+
|
|
14
|
+
Inspirerat av kodlas-rullning (parallella siffror som rullar med
|
|
15
|
+
olika sluttider). Triggers:
|
|
16
|
+
1. **Fresh session** (J.5): kallstart-detektering via sessionStorage-
|
|
17
|
+
marker. Animation kor vid forsta page-load efter att PWA/tab
|
|
18
|
+
stangts helt. Sprint D3:s `body[data-first-load]` (cookie-baserad)
|
|
19
|
+
fungerar bara for helt nya anvandare; J.5-trigger fungerar for
|
|
20
|
+
return-anvandare som har `ekonom_seen`-cookie satt sedan tidigare.
|
|
21
|
+
2. Manadsbyte via < / > i manads-pillen pa /avstamning
|
|
22
|
+
|
|
23
|
+
Sprint J.4 (CE-rapport: 000 kr syns lite val lange + hela processen
|
|
24
|
+
ar lang):
|
|
25
|
+
- Turbo-nav.js skip:ar VT vid month-change. Original DOM byts
|
|
26
|
+
omedelbart till rollers - ingen 160ms VT-fade-paus. Animation
|
|
27
|
+
borjar pa samma frame som DOM-swap.
|
|
28
|
+
- Korta animation-durations: BASE 800ms -> 500ms, STEP 200ms ->
|
|
29
|
+
100ms. Total animation 500/600/700ms istallet for 800/1000/1200ms.
|
|
30
|
+
- Riv ut J.3:s tva-fas-arkitektur (BUILD/ANIMATE separation). Allt
|
|
31
|
+
sker synchron inom buildAndAnimate.
|
|
32
|
+
- Behaller force-reflow-pattern (`void el.offsetHeight`) sa browser
|
|
33
|
+
registrerar initial state innan transition triggas.
|
|
34
|
+
|
|
35
|
+
Total perceived tid (klick till slutvarde): ~720ms vid month-change
|
|
36
|
+
(network ~50ms + animation 700ms). Tidigare: ~1400ms.
|
|
37
|
+
|
|
38
|
+
Bredd-glitch-fix (J.3 oforandrat):
|
|
39
|
+
Default `<p>`-block-flow med inline-block rollers. Content-driven
|
|
40
|
+
width via tabular-nums - identisk packning som server-text.
|
|
41
|
+
--------------------------------------------------------------*/
|
|
42
|
+
( function () {
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
// Sprint J.4: kortare durations. 500/600/700ms for tre siffror.
|
|
46
|
+
const ROLLER_DURATION_BASE = 500; // var 800ms
|
|
47
|
+
const ROLLER_DURATION_STEP = 100; // var 200ms
|
|
48
|
+
const EXTRA_ROUNDS = 3;
|
|
49
|
+
// Sprint J.6: linear istallet for cubic-bezier(0.22, 1, 0.36, 1)
|
|
50
|
+
// (ease-out-quart). Quart-easing packar 95%+ av rorelsen i forsta
|
|
51
|
+
// halvan av tiden - sista 10% av tiden tacker bara 0.01% av rorelse,
|
|
52
|
+
// vilket gor sista rollers slutfas visuellt ororlig ("byts ut" istallet
|
|
53
|
+
// for "rullar"). Linear ger konstant hastighet sa alla siffror rullar
|
|
54
|
+
// synligt fran start till slut. Aven mer mekaniskt-korrekt - faktiska
|
|
55
|
+
// kodlas rullar med moment-konstant rotation.
|
|
56
|
+
const EASING = 'linear';
|
|
57
|
+
|
|
58
|
+
// Sprint J.5: sessionStorage-marker for fresh-session-detektering.
|
|
59
|
+
// sessionStorage rensas vid PWA-stangning/tab-close, persisterar
|
|
60
|
+
// mellan reloads/nav inom samma session.
|
|
61
|
+
// Delad sessionStorage-key for hero-roll. Bytt fran ekonom_hero_session
|
|
62
|
+
// 2026-05-08 nar komponenten porterades tillbaka till Ekonom som shared-
|
|
63
|
+
// system. Befintliga session-markers fran gamla nyckeln tappar persist
|
|
64
|
+
// engang per anvandare - ny markering satts vid nasta kallstart.
|
|
65
|
+
const SESSION_KEY = 'ds_hero_session';
|
|
66
|
+
|
|
67
|
+
let hasRunInitial = false;
|
|
68
|
+
let lastSeenUrl = window.location.href;
|
|
69
|
+
|
|
70
|
+
function getHeroEl () {
|
|
71
|
+
return document.querySelector( '.hero-amount[data-animate-roll]' );
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function prefersReducedMotion () {
|
|
75
|
+
return window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildRoller ( targetDigit ) {
|
|
79
|
+
const roller = document.createElement( 'span' );
|
|
80
|
+
roller.className = 'hero-digit-roller';
|
|
81
|
+
|
|
82
|
+
const track = document.createElement( 'span' );
|
|
83
|
+
track.className = 'hero-digit-track';
|
|
84
|
+
|
|
85
|
+
const totalSteps = targetDigit + ( EXTRA_ROUNDS * 10 );
|
|
86
|
+
for ( let i = 0; i <= totalSteps; i++ ) {
|
|
87
|
+
const span = document.createElement( 'span' );
|
|
88
|
+
span.textContent = String( i % 10 );
|
|
89
|
+
track.appendChild( span );
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
roller.appendChild( track );
|
|
93
|
+
return { roller: roller, track: track, steps: totalSteps };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildStaticChar ( text ) {
|
|
97
|
+
const span = document.createElement( 'span' );
|
|
98
|
+
span.className = 'hero-static-char';
|
|
99
|
+
span.textContent = text;
|
|
100
|
+
return span;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*--------------------------------------------------------------
|
|
104
|
+
Bygg digit-rollers + starta transition synchron i samma frame.
|
|
105
|
+
Idempotent via data-rolled-attribut.
|
|
106
|
+
--------------------------------------------------------------*/
|
|
107
|
+
function buildAndAnimate ( el ) {
|
|
108
|
+
if ( ! el ) return;
|
|
109
|
+
if ( el.dataset.rolled === 'true' ) return;
|
|
110
|
+
const target = parseInt( el.dataset.animateRoll, 10 );
|
|
111
|
+
if ( isNaN( target ) ) return;
|
|
112
|
+
|
|
113
|
+
// No-op for 0
|
|
114
|
+
if ( target === 0 ) {
|
|
115
|
+
el.dataset.rolled = 'true';
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
el.dataset.rolled = 'true';
|
|
120
|
+
|
|
121
|
+
const originalText = el.textContent;
|
|
122
|
+
const isNegative = target < 0;
|
|
123
|
+
const absTarget = Math.abs( target );
|
|
124
|
+
const digits = String( absTarget ).split( '' ).map( Number );
|
|
125
|
+
const len = digits.length;
|
|
126
|
+
|
|
127
|
+
const fragment = document.createDocumentFragment();
|
|
128
|
+
|
|
129
|
+
if ( isNegative ) {
|
|
130
|
+
fragment.appendChild( buildStaticChar( '−' ) );
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rollers = [];
|
|
134
|
+
digits.forEach( function ( d, i ) {
|
|
135
|
+
const r = buildRoller( d );
|
|
136
|
+
fragment.appendChild( r.roller );
|
|
137
|
+
rollers.push( r );
|
|
138
|
+
const fromRight = len - 1 - i;
|
|
139
|
+
if ( fromRight > 0 && fromRight % 3 === 0 ) {
|
|
140
|
+
fragment.appendChild( buildStaticChar( ' ' ) );
|
|
141
|
+
}
|
|
142
|
+
} );
|
|
143
|
+
|
|
144
|
+
fragment.appendChild( buildStaticChar( ' kr' ) );
|
|
145
|
+
|
|
146
|
+
// Mat font-line-height for digit-roller-hojd.
|
|
147
|
+
const computed = window.getComputedStyle( el );
|
|
148
|
+
const fontSize = parseFloat( computed.fontSize );
|
|
149
|
+
const lineHeightRaw = computed.lineHeight;
|
|
150
|
+
let lineHeight;
|
|
151
|
+
if ( lineHeightRaw === 'normal' ) {
|
|
152
|
+
lineHeight = fontSize * 1.05;
|
|
153
|
+
} else {
|
|
154
|
+
lineHeight = parseFloat( lineHeightRaw );
|
|
155
|
+
}
|
|
156
|
+
el.style.setProperty( '--hero-digit-height', lineHeight + 'px' );
|
|
157
|
+
|
|
158
|
+
// Replace content
|
|
159
|
+
el.innerHTML = '';
|
|
160
|
+
el.appendChild( fragment );
|
|
161
|
+
|
|
162
|
+
// Reduced-motion: hopppa direkt till slutposition utan transition.
|
|
163
|
+
if ( prefersReducedMotion() ) {
|
|
164
|
+
rollers.forEach( function ( r ) {
|
|
165
|
+
r.track.style.transform =
|
|
166
|
+
'translateY(calc(-1 * var(--hero-digit-height) * ' + r.steps + '))';
|
|
167
|
+
} );
|
|
168
|
+
requestAnimationFrame( function () {
|
|
169
|
+
el.textContent = originalText;
|
|
170
|
+
} );
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Sprint J.4: force reflow sa browser registrerar initial state
|
|
175
|
+
// (translateY default 0) innan transition triggas. Utan reflow
|
|
176
|
+
// hoppar browser direkt till slut-state utan animation.
|
|
177
|
+
void el.offsetHeight;
|
|
178
|
+
|
|
179
|
+
// Trigga transition synchron i samma frame.
|
|
180
|
+
rollers.forEach( function ( r, i ) {
|
|
181
|
+
const duration = ROLLER_DURATION_BASE + ( i * ROLLER_DURATION_STEP );
|
|
182
|
+
r.track.style.transition =
|
|
183
|
+
'transform ' + duration + 'ms ' + EASING;
|
|
184
|
+
r.track.style.transform =
|
|
185
|
+
'translateY(calc(-1 * var(--hero-digit-height) * ' + r.steps + '))';
|
|
186
|
+
} );
|
|
187
|
+
|
|
188
|
+
// Vid done: restora till server-text for klean DOM-state.
|
|
189
|
+
const totalDuration = ROLLER_DURATION_BASE + ( ( rollers.length - 1 ) * ROLLER_DURATION_STEP );
|
|
190
|
+
setTimeout( function () {
|
|
191
|
+
el.textContent = originalText;
|
|
192
|
+
}, totalDuration + 60 );
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*--------------------------------------------------------------
|
|
196
|
+
Trigger-detektering.
|
|
197
|
+
--------------------------------------------------------------*/
|
|
198
|
+
function isMonthChange ( fromUrl, toUrl ) {
|
|
199
|
+
if ( ! fromUrl ) return false;
|
|
200
|
+
try {
|
|
201
|
+
const f = new URL( fromUrl );
|
|
202
|
+
const t = new URL( toUrl );
|
|
203
|
+
if ( f.pathname !== '/avstamning' || t.pathname !== '/avstamning' ) return false;
|
|
204
|
+
const fromMonth = f.searchParams.get( 'month' );
|
|
205
|
+
const toMonth = t.searchParams.get( 'month' );
|
|
206
|
+
return fromMonth !== toMonth;
|
|
207
|
+
} catch ( _ ) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/*--------------------------------------------------------------
|
|
213
|
+
Sprint J.5: trigger animation vid fresh session (kallstart fran
|
|
214
|
+
stangd PWA/tab). sessionStorage-marker satts forsta gang per
|
|
215
|
+
session - om saknad ar det en ny session.
|
|
216
|
+
|
|
217
|
+
Behaller `data-first-load`-trigger som fallback for browsers utan
|
|
218
|
+
sessionStorage-stod (extremt sallsynt) och for konsistens med
|
|
219
|
+
Sprint D3 splash-animations.
|
|
220
|
+
--------------------------------------------------------------*/
|
|
221
|
+
function tryAnimateInitial () {
|
|
222
|
+
if ( hasRunInitial ) return;
|
|
223
|
+
|
|
224
|
+
// Fresh-session-detektering via sessionStorage.
|
|
225
|
+
let isFreshSession = false;
|
|
226
|
+
try {
|
|
227
|
+
if ( ! sessionStorage.getItem( SESSION_KEY ) ) {
|
|
228
|
+
isFreshSession = true;
|
|
229
|
+
sessionStorage.setItem( SESSION_KEY, '1' );
|
|
230
|
+
}
|
|
231
|
+
} catch ( _ ) {
|
|
232
|
+
// sessionStorage kan kasta i restricted contexts (extremt
|
|
233
|
+
// sallsynt). Falla tillbaka till data-first-load.
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const isFirstEver = document.body.dataset.firstLoad === 'true';
|
|
237
|
+
|
|
238
|
+
if ( ! isFreshSession && ! isFirstEver ) return;
|
|
239
|
+
|
|
240
|
+
const el = getHeroEl();
|
|
241
|
+
if ( ! el ) return;
|
|
242
|
+
hasRunInitial = true;
|
|
243
|
+
buildAndAnimate( el );
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/*--------------------------------------------------------------
|
|
247
|
+
First-load via DOMContentLoaded.
|
|
248
|
+
--------------------------------------------------------------*/
|
|
249
|
+
if ( document.readyState === 'loading' ) {
|
|
250
|
+
document.addEventListener( 'DOMContentLoaded', tryAnimateInitial );
|
|
251
|
+
} else {
|
|
252
|
+
tryAnimateInitial();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/*--------------------------------------------------------------
|
|
256
|
+
Sprint J.4: month-change via turbo:swap (synchron inom turbo-nav.js
|
|
257
|
+
swap-kornplats). Eftersom turbo-nav.js skip:ar VT vid month-change,
|
|
258
|
+
ar swap synchron och original DOM ar synlig direkt - animation
|
|
259
|
+
borjar pa samma frame som DOM-byte.
|
|
260
|
+
--------------------------------------------------------------*/
|
|
261
|
+
document.addEventListener( 'turbo:swap', function ( e ) {
|
|
262
|
+
const detail = e.detail || {};
|
|
263
|
+
const newUrl = detail.url || window.location.href;
|
|
264
|
+
|
|
265
|
+
if ( ! isMonthChange( lastSeenUrl, newUrl ) ) {
|
|
266
|
+
lastSeenUrl = newUrl;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const el = getHeroEl();
|
|
271
|
+
if ( ! el ) {
|
|
272
|
+
lastSeenUrl = newUrl;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
buildAndAnimate( el );
|
|
277
|
+
lastSeenUrl = newUrl;
|
|
278
|
+
} );
|
|
279
|
+
|
|
280
|
+
/*--------------------------------------------------------------
|
|
281
|
+
Fallback: turbo:navigated (efter VT-end) for first-load om
|
|
282
|
+
DOMContentLoaded inte hunnit.
|
|
283
|
+
--------------------------------------------------------------*/
|
|
284
|
+
document.addEventListener( 'turbo:navigated', function () {
|
|
285
|
+
tryAnimateInitial();
|
|
286
|
+
} );
|
|
287
|
+
} )();
|
package/js/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* @klodd/ds - JavaScript entry point.
|
|
2
|
+
*
|
|
3
|
+
* Convenience-aggregator som kor enskilda init-funktioner. Default
|
|
4
|
+
* fungerar utan konfiguration (LLM-vanligt). Konfig-objektet kan
|
|
5
|
+
* over-rida vad som ska aktiveras.
|
|
6
|
+
*
|
|
7
|
+
* Anvandning:
|
|
8
|
+
*
|
|
9
|
+
* <script src="https://unpkg.com/@klodd/ds/js/index.js" defer></script>
|
|
10
|
+
* <script>
|
|
11
|
+
* window.addEventListener('DOMContentLoaded', () => {
|
|
12
|
+
* KloddDS.init();
|
|
13
|
+
* });
|
|
14
|
+
* </script>
|
|
15
|
+
*
|
|
16
|
+
* Eller med konfig:
|
|
17
|
+
*
|
|
18
|
+
* KloddDS.init({
|
|
19
|
+
* swPath: '/service-worker.js',
|
|
20
|
+
* lucide: true,
|
|
21
|
+
* barStyles: true,
|
|
22
|
+
* heroRoll: true,
|
|
23
|
+
* pullToRefresh: false,
|
|
24
|
+
* turboNav: false,
|
|
25
|
+
* navOptimistic: true,
|
|
26
|
+
* sheetDrag: true,
|
|
27
|
+
* pwaRegister: false, // satt true + swPath for SW + install-prompts
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* Individuella moduler kan ocksa importeras separat fran js/-mappen.
|
|
31
|
+
* KloddDS.init() ar bara en convenience-wrapper - den inkluderar inte
|
|
32
|
+
* filerna sjalv, dvs varje modul-fil maste laddas via <script>-tag.
|
|
33
|
+
*
|
|
34
|
+
* Defaults ar konservativa: heavy beteenden (PtR, Turbo Drive, PWA
|
|
35
|
+
* register) ar OFF default. App-mantainern aktiverar opt-in.
|
|
36
|
+
*/
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
(function (root) {
|
|
40
|
+
const defaults = {
|
|
41
|
+
lucide: true,
|
|
42
|
+
barStyles: true,
|
|
43
|
+
heroRoll: true,
|
|
44
|
+
navOptimistic: true,
|
|
45
|
+
sheetDrag: true,
|
|
46
|
+
pullToRefresh: false,
|
|
47
|
+
turboNav: false,
|
|
48
|
+
pwaRegister: false,
|
|
49
|
+
swPath: '/static/sw.js',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const KloddDS = {
|
|
53
|
+
init(config) {
|
|
54
|
+
const cfg = Object.assign({}, defaults, config || {});
|
|
55
|
+
|
|
56
|
+
if (cfg.lucide && typeof root.initLucide === 'function') {
|
|
57
|
+
root.initLucide();
|
|
58
|
+
} else if (cfg.lucide && root.lucide && typeof root.lucide.createIcons === 'function') {
|
|
59
|
+
root.lucide.createIcons();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (cfg.barStyles && typeof root.initBarStyles === 'function') {
|
|
63
|
+
root.initBarStyles();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (cfg.heroRoll && typeof root.initHeroRoll === 'function') {
|
|
67
|
+
root.initHeroRoll();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (cfg.navOptimistic && typeof root.initNavOptimistic === 'function') {
|
|
71
|
+
root.initNavOptimistic();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (cfg.sheetDrag && typeof root.initSheetDrag === 'function') {
|
|
75
|
+
root.initSheetDrag();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cfg.pullToRefresh && typeof root.initPullToRefresh === 'function') {
|
|
79
|
+
root.initPullToRefresh();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cfg.turboNav && typeof root.initTurboNav === 'function') {
|
|
83
|
+
root.initTurboNav();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cfg.pwaRegister && typeof root.initPwaRegister === 'function') {
|
|
87
|
+
root.initPwaRegister({ swPath: cfg.swPath });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
version: '2.0.0',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
root.KloddDS = KloddDS;
|
|
95
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -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
|
+
})();
|