feat: Add jump-to-page dialog and improve pagination logic and styling (#1303)
* feat: add jump-to-page dialog on pagination ellipsis - Replace pagination windowing algorithm with a tighter 5-item window (curr-1 / curr / curr+1, clamped at edges) and hide-on-mobile for pages more than 1 step away from the current page - Ellipsis spans now carry the pagination-jump-trigger class and open a native <dialog> on click instead of being static decorations - Add <dialog id="pagination-jump-dialog"> with number input, Go button, and Enter-key hint; data-total / data-first-url / data-format-url attributes on <nav> give the JS all it needs to build the target URL - Add assets/ts/pagination.ts: setupPaginationJump() with Fisher-Yates backdrop-click and ESC handling, animated open/close via .closing class - Wire setupPaginationJump() into Stack.init() in main.ts - Extend pagination.scss with .hide-on-mobile, .pagination-jump-trigger hover style, and the full #pagination-jump-dialog styling + keyframe scale-up/scale-down animations - Add [pagination] i18n keys (jumpToPage / jump / pressEnter) to en / zh / zh-hant-tw / zh-hant-hk / ja * style: align pagination code with upstream conventions - pagination.html: collapse multi-line span/class/aria attrs to single lines; use HTML5 void element syntax; fix hint div indentation - pagination.scss: 2-space indent → 4-space indent - pagination.ts: tab indent → 4-space indent; export const arrow fn → export function declaration * chore: update layouts/_partials/pagination.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: add aria-labelledby attrributes for better accesibility Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: use { once: true } for the listener and guard against re-entry when .closing is already set. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * style: replace hardcoded values with design tokens and improve dialog UX - Replace all hardcoded px spacing in .page-link with --spacing-* tokens - Use @include card-style in #pagination-jump-dialog; override with --shadow-l2 - Replace padding: 24px with var(--card-padding) for responsive dialog padding - Align header title/subtitle to space-between for cleaner layout - Replace hardcoded gaps/padding in form with spacing tokens - Fix input/button border-radius from 8px to 6px (better proportion for 44px height) - Fix focus ring: use color-mix(accent-color 20%) so it's visible in dark mode - Add :focus-visible + outline:none to fully suppress native browser outline - Remove translateY from button hover for more restrained interaction - Scope transition to specific properties (border-color, box-shadow, background-color) * chore: add pagination test articles for easier debugging * style: enhance backdrop animations for pagination jump dialog * style: replace pagination ellipsis spans with buttons for improved accessibility * fix: check if browser supports dialog API before continue with pagination jump setup * fix: retain focus on last active element when closing pagination jump dialog * fix: style for page-link button (pagination-jump) * style: adjust width of pagination jump dialog, add container padding * style: simplify * refactor: simplify pagination link logic --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jimmy Cai <jimmy@cai.im>
This commit is contained in:
co-authored by
Copilot
Jimmy Cai
parent
8e3687431f
commit
37f6a77a1a
@@ -0,0 +1,104 @@
|
||||
export function setupPaginationJump() {
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>('.pagination-jump-trigger');
|
||||
const dialog = document.getElementById('pagination-jump-dialog') as HTMLDialogElement;
|
||||
|
||||
if (!dialog || triggers.length === 0) return;
|
||||
|
||||
const nav = document.querySelector('.pagination') as HTMLElement;
|
||||
const input = document.getElementById('pagination-jump-input') as HTMLInputElement;
|
||||
const form = dialog.querySelector('.pagination-jump-form') as HTMLFormElement;
|
||||
const supportsDialog = typeof dialog.showModal === 'function' && typeof dialog.close === 'function';
|
||||
let lastFocusedElement: HTMLElement | null = null;
|
||||
|
||||
if (!supportsDialog || !nav || !input || !form) return;
|
||||
|
||||
const closeDialog = (): void => {
|
||||
if (dialog.classList.contains('closing')) return;
|
||||
dialog.classList.add('closing');
|
||||
dialog.addEventListener(
|
||||
'animationend',
|
||||
() => {
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if (lastFocusedElement?.isConnected) {
|
||||
lastFocusedElement.focus();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Open dialog when triggers are clicked
|
||||
triggers.forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const activeElement = document.activeElement;
|
||||
lastFocusedElement = activeElement instanceof HTMLElement ? activeElement : trigger;
|
||||
dialog.showModal();
|
||||
input.value = '';
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle ESC key closing the dialog
|
||||
dialog.addEventListener('cancel', (e) => {
|
||||
e.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
|
||||
// Close dialog when clicking backdrop
|
||||
dialog.addEventListener('click', (e) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const isInDialog =
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Allow Enter key to trigger validation and submit
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (form.reportValidity()) {
|
||||
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPage = parseInt(input.value);
|
||||
if (isNaN(targetPage) || targetPage < 1) return;
|
||||
|
||||
const totalPages = parseInt(nav.dataset.total || '0');
|
||||
if (targetPage > totalPages) return;
|
||||
|
||||
const firstUrl = nav.dataset.firstUrl || '';
|
||||
const formatUrl = nav.dataset.formatUrl || '';
|
||||
|
||||
let targetUrl = '';
|
||||
if (targetPage === 1) {
|
||||
targetUrl = firstUrl;
|
||||
} else {
|
||||
// formatUrl is the URL for page 2. E.g., /tags/page/2/ or /page/2/
|
||||
// Replace the '2' before the trailing slash or .html with the target page number
|
||||
targetUrl = formatUrl.replace(/2([^\d]*)$/, `${targetPage}$1`);
|
||||
}
|
||||
|
||||
if (targetUrl) {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user