mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
fix(system-notices): overhaul mobile bottom sheet UX
- Replace "Next Notice >" CTA with proper < > pager buttons - Fix shared scroll container: each slot now scrolls independently - Sheet uses fixed h-[85dvh] so height is consistent across all notices - Sticky footer (pager + CTA) always anchored at bottom of each slot - Content area vertically centered when shorter than available space - Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back) - Scroll position resets on navigation via per-slot refs - Adjacent slot scroll cleared on horizontal gesture classification - OK button navigates to next notice on non-last pages, dismisses on last - OK button only shown when dismissible or on last notice
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
@@ -71,18 +71,20 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
: DefaultIcon;
|
: DefaultIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col relative flex-1">
|
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||||
{/* Dismiss X button — only on last page so users read all notices */}
|
{/* Dismiss X button — only on last page so users read all notices */}
|
||||||
{notice.dismissible && isLastPage && (
|
{notice.dismissible && isLastPage && (
|
||||||
<button
|
<button
|
||||||
onClick={onDismissAll}
|
onClick={onDismissAll}
|
||||||
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable content — vertically centered when shorter than available space */}
|
||||||
|
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
|
||||||
{/* Hero image (not inline) */}
|
{/* Hero image (not inline) */}
|
||||||
{notice.media && notice.media.placement !== 'inline' && (
|
{notice.media && notice.media.placement !== 'inline' && (
|
||||||
<div
|
<div
|
||||||
@@ -116,7 +118,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col flex-1`}>
|
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
|
||||||
{/* Severity icon (when no hero and not Heart) */}
|
{/* Severity icon (when no hero and not Heart) */}
|
||||||
{!notice.media && notice.icon !== 'Heart' && (
|
{!notice.media && notice.icon !== 'Heart' && (
|
||||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||||
@@ -134,7 +136,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Body — markdown (long body text uses left-aligned layout) */}
|
{/* Body — markdown */}
|
||||||
<div
|
<div
|
||||||
id={bodyId}
|
id={bodyId}
|
||||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||||
@@ -214,16 +216,23 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
|
||||||
|
<div
|
||||||
|
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
|
||||||
|
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
|
||||||
|
>
|
||||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||||
{total > 1 && (
|
{total > 1 && (
|
||||||
<div className="flex flex-col items-center gap-1 mt-6 mb-0">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={!canPage || currentPage === 0}
|
disabled={!canPage || currentPage === 0}
|
||||||
aria-label={t('system_notice.pager.prev')}
|
aria-label={t('system_notice.pager.prev')}
|
||||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} />
|
<ChevronLeft size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -247,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canPage || currentPage === total - 1}
|
disabled={!canPage || currentPage === total - 1}
|
||||||
aria-label={t('system_notice.pager.next')}
|
aria-label={t('system_notice.pager.next')}
|
||||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -262,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA + dismiss link */}
|
{/* CTA + dismiss link */}
|
||||||
<div className="flex flex-col items-center gap-3 mt-auto">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{!isLastPage && total > 1 ? (
|
{ctaLabel && isLastPage ? (
|
||||||
/* Non-last page: "Next" button to advance through all notices */
|
|
||||||
<button
|
|
||||||
id={`notice-cta-${notice.id}`}
|
|
||||||
onClick={onNext}
|
|
||||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{t('system_notice.pager.next')} <ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
) : ctaLabel ? (
|
|
||||||
<button
|
<button
|
||||||
id={`notice-cta-${notice.id}`}
|
id={`notice-cta-${notice.id}`}
|
||||||
onClick={onCTA}
|
onClick={onCTA}
|
||||||
@@ -280,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (notice.dismissible || isLastPage) && (
|
||||||
<button
|
<button
|
||||||
id={`notice-cta-${notice.id}`}
|
id={`notice-cta-${notice.id}`}
|
||||||
onClick={onDismissAll}
|
onClick={isLastPage ? onDismissAll : onNext}
|
||||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{t('common.ok')}
|
{t('common.ok')}
|
||||||
@@ -332,6 +332,9 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
const touchStartY = useRef<number | null>(null);
|
const touchStartY = useRef<number | null>(null);
|
||||||
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
||||||
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
||||||
|
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
|
||||||
|
// when the user is scrolled into content and pans down to scroll back up.
|
||||||
|
const scrollTopAtTouchStart = useRef(0);
|
||||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||||
const noticeIdRef = useRef<string | null>(null);
|
const noticeIdRef = useRef<string | null>(null);
|
||||||
noticeIdRef.current = notice?.id ?? null;
|
noticeIdRef.current = notice?.id ?? null;
|
||||||
@@ -347,10 +350,11 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
const stripRef = useRef<HTMLDivElement>(null);
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
// The sheet element itself — animated on vertical drag-to-dismiss
|
// The sheet element itself — animated on vertical drag-to-dismiss
|
||||||
const sheetRef = useRef<HTMLDivElement>(null);
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
// Clip container ref + cached max height — used to pin sheet height to tallest notice
|
|
||||||
const clipRef = useRef<HTMLDivElement>(null);
|
const clipRef = useRef<HTMLDivElement>(null);
|
||||||
const maxClipHeightRef = useRef(0);
|
// Individual slot scroll containers (prev / center / next)
|
||||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
const prevSlotRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
|
||||||
|
const nextSlotRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Mobile breakpoint
|
// Mobile breakpoint
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -466,18 +470,11 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [visible, notice]);
|
}, [visible, notice]);
|
||||||
|
|
||||||
// Pin the strip to the tallest notice height seen so far.
|
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
|
||||||
// Setting minHeight on the strip (not the clip) forces align-items:stretch to
|
useEffect(() => {
|
||||||
// make every slot exactly that tall, so mt-auto always bottoms out at the same Y.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
const el = stripRef.current;
|
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||||
if (!el) return;
|
}, [idx, isMobile]);
|
||||||
el.style.minHeight = '';
|
|
||||||
const h = el.scrollHeight;
|
|
||||||
if (h > maxClipHeightRef.current) maxClipHeightRef.current = h;
|
|
||||||
el.style.minHeight = `${maxClipHeightRef.current}px`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function announceIndex(newIdx: number, total: number) {
|
function announceIndex(newIdx: number, total: number) {
|
||||||
setPageAnnouncement(
|
setPageAnnouncement(
|
||||||
@@ -659,12 +656,13 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={bodyId}
|
aria-describedby={bodyId}
|
||||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
||||||
style={{ paddingBottom: 'var(--bottom-nav-h)', touchAction: 'pan-y' }}
|
style={{ touchAction: 'pan-y' }}
|
||||||
onTouchStart={e => {
|
onTouchStart={e => {
|
||||||
touchStartX.current = e.touches[0].clientX;
|
touchStartX.current = e.touches[0].clientX;
|
||||||
touchStartY.current = e.touches[0].clientY;
|
touchStartY.current = e.touches[0].clientY;
|
||||||
dragLockRef.current = null;
|
dragLockRef.current = null;
|
||||||
|
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
|
||||||
}}
|
}}
|
||||||
onTouchMove={e => {
|
onTouchMove={e => {
|
||||||
if (prefersReducedMotion) return;
|
if (prefersReducedMotion) return;
|
||||||
@@ -675,8 +673,14 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
const dy = e.touches[0].clientY - startY;
|
const dy = e.touches[0].clientY - startY;
|
||||||
// Classify gesture direction on first significant movement
|
// Classify gesture direction on first significant movement
|
||||||
if (!dragLockRef.current) {
|
if (!dragLockRef.current) {
|
||||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8)
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||||
|
// Reset adjacent slots to top before they slide into view.
|
||||||
|
if (dragLockRef.current === 'h') {
|
||||||
|
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||||
|
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dragLockRef.current === 'h') {
|
if (dragLockRef.current === 'h') {
|
||||||
@@ -686,6 +690,9 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||||
|
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
|
||||||
|
// If scrolled into content, let native pan-y scroll it back up.
|
||||||
|
if (scrollTopAtTouchStart.current > 0) return;
|
||||||
const sheet = sheetRef.current;
|
const sheet = sheetRef.current;
|
||||||
if (!sheet || dy <= 0) return;
|
if (!sheet || dy <= 0) return;
|
||||||
sheet.style.transition = 'none';
|
sheet.style.transition = 'none';
|
||||||
@@ -726,6 +733,10 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
setIdx(newIdx);
|
setIdx(newIdx);
|
||||||
announceIndex(newIdx, notices.length);
|
announceIndex(newIdx, notices.length);
|
||||||
});
|
});
|
||||||
|
// Reset all slot scrolls so the new center starts at top.
|
||||||
|
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||||
|
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||||
|
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||||
strip.style.transform = 'translateX(-33.333%)';
|
strip.style.transform = 'translateX(-33.333%)';
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -741,8 +752,8 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical drag — animated dismiss or spring back
|
// Vertical drag — animated dismiss or spring back (only when at scroll top)
|
||||||
if (lock === 'v' && startY !== null) {
|
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
|
||||||
const deltaY = e.changedTouches[0].clientY - startY;
|
const deltaY = e.changedTouches[0].clientY - startY;
|
||||||
const sheet = sheetRef.current;
|
const sheet = sheetRef.current;
|
||||||
if (deltaY > 80 && notice.dismissible) {
|
if (deltaY > 80 && notice.dismissible) {
|
||||||
@@ -759,24 +770,24 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Drag handle */}
|
{/* Drag handle — fixed, does not scroll */}
|
||||||
<div className="pt-3 pb-1 flex justify-center">
|
<div className="pt-3 pb-1 flex justify-center shrink-0">
|
||||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
{/* Clip container — hides the adjacent slots outside the sheet width */}
|
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
|
||||||
<div style={{ overflow: 'hidden', width: '100%' }}>
|
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
|
||||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||||
<div
|
<div
|
||||||
ref={stripRef}
|
ref={stripRef}
|
||||||
style={{ display: 'flex', width: '300%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
||||||
</div>
|
</div>
|
||||||
<div ref={contentWrapperRef} style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
<NoticeContent {...contentProps} />
|
<NoticeContent {...contentProps} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user