mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: scroll-position memory on repertoire HTMX swaps + swap transition polish
- Add repertoire-scroll-restore.js: snapshots scrollTop of each column <ul> before htmx:beforeSwap, restores after htmx:afterSwap (keyed by data-col) - Add subtle opacity transition on #repertoire-index during htmx-swapping - Tighten rep-indicator opacity transition to 0.1s for snappier feedback - Import new module in public-entry.js
This commit is contained in:
15
TODO.md
15
TODO.md
@@ -3,6 +3,13 @@
|
|||||||
> Last updated: 2026-06-24
|
> Last updated: 2026-06-24
|
||||||
> Context: Security audit — fix open redirects, fragment auth, dead code, CSRF gaps
|
> Context: Security audit — fix open redirects, fragment auth, dead code, CSRF gaps
|
||||||
|
|
||||||
|
## Deferred / Blocked
|
||||||
|
- [ ] #tighten-csp Tighten CSP to remove 'unsafe-inline' after inline JS extraction
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
- [ ] #rep-student-touch Replace hover student popover with tap-to-open drawer for mobile `(repertoire.php, repertoire.css, repertoire-student-popover.js)`
|
||||||
|
- [x] #rep-polish Polish: scroll-position memory on HTMX swap, animation tuning `(repertoire.css)` ✓
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- [x] #icon-color-verify Verify icon colors render correctly across all pages (header, admin tables, forms, dialogs, cleanup modal) ✓
|
- [x] #icon-color-verify Verify icon colors render correctly across all pages (header, admin tables, forms, dialogs, cleanup modal) ✓
|
||||||
- [x] #sec-open-redirect Fix open redirect in tag.php + language.php (protocol-relative URL bypass via str_starts_with) ✓
|
- [x] #sec-open-redirect Fix open redirect in tag.php + language.php (protocol-relative URL bypass via str_starts_with) ✓
|
||||||
@@ -18,12 +25,6 @@
|
|||||||
- [x] #sec-fragments-auth Gate partagé fragments on share_active session (read-only fragment renderers — no CSRF needed) ✓
|
- [x] #sec-fragments-auth Gate partagé fragments on share_active session (read-only fragment renderers — no CSRF needed) ✓
|
||||||
- [x] #sec-retry-csrf Add CSRF check to partage/retry-email.php POST ✓
|
- [x] #sec-retry-csrf Add CSRF check to partage/retry-email.php POST ✓
|
||||||
- [x] #sec-cleanup-dead-code Remove dead App::verifyCsrf() or refactor action handlers to use it ✓
|
- [x] #sec-cleanup-dead-code Remove dead App::verifyCsrf() or refactor action handlers to use it ✓
|
||||||
|
|
||||||
## Pending
|
|
||||||
- [ ] #rep-student-touch Replace hover student popover with tap-to-open drawer for mobile `(repertoire.php, repertoire.css, repertoire-student-popover.js)`
|
|
||||||
- [ ] #rep-polish Polish: scroll-position memory on HTMX swap, animation tuning `(repertoire.css)`
|
|
||||||
|
|
||||||
## Completed (before this session)
|
|
||||||
- [x] #gzip-nginx Enable gzip compression in nginx config `(nginx/xamxam.conf)` ✓
|
- [x] #gzip-nginx Enable gzip compression in nginx config `(nginx/xamxam.conf)` ✓
|
||||||
- [x] #extract-inline-js Move inline JS to external files across 17 templates → 15 new JS files created `(app/public/assets/js/app/*.js)` ✓
|
- [x] #extract-inline-js Move inline JS to external files across 17 templates → 15 new JS files created `(app/public/assets/js/app/*.js)` ✓
|
||||||
- [x] #inline-icon-helper Create `icon()` PHP helper + auto-load in bootstrap `(src/icon.php, bootstrap.php)` ✓
|
- [x] #inline-icon-helper Create `icon()` PHP helper + auto-load in bootstrap `(src/icon.php, bootstrap.php)` ✓
|
||||||
@@ -77,5 +78,3 @@
|
|||||||
- [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css` ✓
|
- [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css` ✓
|
||||||
- [x] #extra-css-admin Update `head.php` to support `$extraCssAdmin` for admin-only stylesheets `(head.php)` ✓
|
- [x] #extra-css-admin Update `head.php` to support `$extraCssAdmin` for admin-only stylesheets `(head.php)` ✓
|
||||||
|
|
||||||
## Deferred / Blocked
|
|
||||||
- [ ] #tighten-csp Tighten CSP to remove 'unsafe-inline' after inline JS extraction
|
|
||||||
|
|||||||
@@ -290,6 +290,17 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Swap transition ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.repertoire-index.htmx-swapping {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repertoire-index {
|
||||||
|
transition: opacity 0.15s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
/* Link variant (students col) — no underline by default */
|
/* Link variant (students col) — no underline by default */
|
||||||
.rep-entry--link {
|
.rep-entry--link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -333,7 +344,7 @@
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.1s;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import "./htmx-global-setup.js";
|
|||||||
|
|
||||||
// Public page features
|
// Public page features
|
||||||
import "./repertoire-accordion.js";
|
import "./repertoire-accordion.js";
|
||||||
|
import "./repertoire-scroll-restore.js";
|
||||||
import "./repertoire-student-popover.js";
|
import "./repertoire-student-popover.js";
|
||||||
import "./access-request.js";
|
import "./access-request.js";
|
||||||
import "./acces-password.js";
|
import "./acces-password.js";
|
||||||
|
|||||||
70
app/public/assets/js/app/repertoire-scroll-restore.js
Normal file
70
app/public/assets/js/app/repertoire-scroll-restore.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* repertoire-scroll-restore.js — Preserve column scroll positions on HTMX swap.
|
||||||
|
*
|
||||||
|
* When a filter button triggers an HTMX swap that replaces #repertoire-index,
|
||||||
|
* the new markup replaces the old, resetting all column scroll positions to 0.
|
||||||
|
* This module captures scrollTop of each scrollable <ul> before the swap and
|
||||||
|
* restores it after, keyed by data-col attribute so the mapping survives
|
||||||
|
* DOM replacement.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
var INDEX_SEL = '#repertoire-index';
|
||||||
|
var scrollSnapshots = {};
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
var index = document.querySelector(INDEX_SEL);
|
||||||
|
if (!index) return;
|
||||||
|
scrollSnapshots = {};
|
||||||
|
index.querySelectorAll('.repertoire-col[data-col] > ul').forEach((ul) => {
|
||||||
|
var col = ul.closest('.repertoire-col');
|
||||||
|
if (!col) return;
|
||||||
|
var key = col.getAttribute('data-col');
|
||||||
|
scrollSnapshots[key] = ul.scrollTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
var index = document.querySelector(INDEX_SEL);
|
||||||
|
if (!index) return;
|
||||||
|
index.querySelectorAll('.repertoire-col[data-col] > ul').forEach((ul) => {
|
||||||
|
var col = ul.closest('.repertoire-col');
|
||||||
|
if (!col) return;
|
||||||
|
var key = col.getAttribute('data-col');
|
||||||
|
if (scrollSnapshots[key] !== undefined) {
|
||||||
|
ul.scrollTop = scrollSnapshots[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture scroll positions before the outgoing element is replaced
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
||||||
|
if (
|
||||||
|
e.detail.target &&
|
||||||
|
e.detail.target.matches &&
|
||||||
|
e.detail.target.matches(INDEX_SEL)
|
||||||
|
) {
|
||||||
|
snapshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore after the new content is in the DOM
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
if (
|
||||||
|
e.detail.target &&
|
||||||
|
e.detail.target.matches &&
|
||||||
|
e.detail.target.matches(INDEX_SEL)
|
||||||
|
) {
|
||||||
|
// Use requestAnimationFrame to ensure layout has settled
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also restore on history navigation (browser back/forward)
|
||||||
|
document.body.addEventListener('htmx:historyRestore', () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user