mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code is removed rather than covered, and the tests that pinned the old behaviour move with them. Also drops two agent coverage scratch files that slipped into the coverage PR and adds the ignore rule that keeps them out.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation } from '../../i18n/index.js';
|
||||
import { isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
@@ -43,39 +42,37 @@ const SEVERITY = {
|
||||
interface BannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function CTALink({
|
||||
notice,
|
||||
cta,
|
||||
label,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: SystemNoticeDTO;
|
||||
cta: NonNullable<SystemNoticeDTO['cta']>;
|
||||
label: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleClick() {
|
||||
if (!notice.cta) return;
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (cta.kind === 'nav') {
|
||||
navigate(cta.href);
|
||||
if (notice.dismissible) onDismiss();
|
||||
} else if (notice.cta.kind === 'link') {
|
||||
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else if (cta.kind === 'link') {
|
||||
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
if (notice.cta.dismissOnAction !== false) onDismiss();
|
||||
runNoticeAction(cta.actionId, { navigate });
|
||||
if (cta.dismissOnAction !== false) onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
if (!notice.cta) return null;
|
||||
|
||||
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
|
||||
if (cta.kind === 'nav' || cta.kind === 'link') {
|
||||
return (
|
||||
<a
|
||||
href={notice.cta.href}
|
||||
href={cta.href}
|
||||
onClick={e => { e.preventDefault(); handleClick(); }}
|
||||
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
||||
>
|
||||
@@ -94,7 +91,7 @@ function CTALink({
|
||||
);
|
||||
}
|
||||
|
||||
function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
function BannerItem({ notice, onDismiss }: BannerItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
||||
const title = t(notice.titleKey);
|
||||
@@ -121,7 +118,7 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
<span className="ml-2 opacity-80">{body}</span>
|
||||
)}
|
||||
{ctaLabel && notice.cta && (
|
||||
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
|
||||
<CTALink notice={notice} cta={notice.cta} label={ctaLabel} onDismiss={onDismiss} />
|
||||
)}
|
||||
</div>
|
||||
{notice.dismissible && (
|
||||
@@ -140,10 +137,9 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
interface AnimatedBannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
|
||||
function AnimatedBannerItem({ notice, onDismiss }: AnimatedBannerItemProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -164,7 +160,7 @@ function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemP
|
||||
|
||||
return (
|
||||
<div className={`${transition} ${state}`}>
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +171,6 @@ interface BannerRendererProps {
|
||||
|
||||
export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { language } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Show at most 2 highest-priority banners
|
||||
@@ -209,7 +204,6 @@ export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
<AnimatedBannerItem
|
||||
notice={notice}
|
||||
onDismiss={() => dismiss(notice.id)}
|
||||
language={language}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -1326,7 +1326,7 @@ describe('ModalRenderer', () => {
|
||||
expect(dismissSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-056: pager buttons of the side slots cannot page past the ends', async () => {
|
||||
it('FE-SN-MODAL-056: the side slots are inert previews, and their pagers cannot page past the ends', async () => {
|
||||
stubMatchMedia({ mobile: true });
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
@@ -1336,6 +1336,14 @@ describe('ModalRenderer', () => {
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// the side slots carry a second copy of the pager and title, so they are
|
||||
// kept out of the a11y tree and the tab order
|
||||
const { prevSlot, nextSlot } = sheetParts();
|
||||
expect(prevSlot.hasAttribute('inert')).toBe(true);
|
||||
expect(prevSlot.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(nextSlot.hasAttribute('inert')).toBe(true);
|
||||
expect(nextSlot.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
// the next slot renders page 2's pager, whose "previous" button is enabled —
|
||||
// pressing it must not move the sheet below the first page
|
||||
const prevButtons = screen.getAllByLabelText('Previous notice') as HTMLButtonElement[];
|
||||
|
||||
@@ -77,7 +77,6 @@ interface ContentProps {
|
||||
titleId: string;
|
||||
bodyId: string;
|
||||
isDark: boolean;
|
||||
onDismiss: () => void;
|
||||
onDismissAll: () => void;
|
||||
onCTA: () => void;
|
||||
onSecondaryCTA: () => void;
|
||||
@@ -90,7 +89,7 @@ interface ContentProps {
|
||||
onGoto: (i: number) => void;
|
||||
}
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||
|
||||
@@ -383,9 +382,6 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
// 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
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
|
||||
// Page-slide animation refs.
|
||||
// isPageNavRef: set to true just before a user-initiated page change so the
|
||||
@@ -532,27 +528,13 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Dismiss current notice. The store removes it from the array, and the next
|
||||
// notice naturally shifts into notices[idx]. The clamp effect handles the
|
||||
// edge case where idx was pointing at the last item.
|
||||
function handleDismissById(id: string) {
|
||||
setVisible(false);
|
||||
dismiss(id);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
const id = noticeIdRef.current;
|
||||
if (id) handleDismissById(id);
|
||||
}
|
||||
|
||||
// Dismiss every notice in the current modal list — used by the X button and ESC.
|
||||
function handleDismissAll() {
|
||||
setVisible(false);
|
||||
notices.forEach(n => dismiss(n.id));
|
||||
}
|
||||
|
||||
function runCta(cta: SystemNoticeDTO['cta']) {
|
||||
if (!cta) { handleDismissAll(); return; }
|
||||
function runCta(cta: NonNullable<SystemNoticeDTO['cta']>) {
|
||||
if (cta.kind === 'nav') {
|
||||
navigate(cta.href);
|
||||
if (notice?.dismissible !== false) handleDismissAll();
|
||||
@@ -565,8 +547,9 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
if (cta.dismissOnAction !== false) handleDismissAll();
|
||||
}
|
||||
}
|
||||
function handleCTA() { runCta(notice?.cta); }
|
||||
function handleSecondaryCTA() { runCta(notice?.secondaryCta); }
|
||||
// Both buttons only render when the notice carries the matching cta.
|
||||
function handleCTA() { runCta(notice!.cta!); }
|
||||
function handleSecondaryCTA() { runCta(notice!.secondaryCta!); }
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
@@ -626,7 +609,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
notice, canPage, isLastPage, language, t, dur, ease,
|
||||
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
|
||||
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
|
||||
announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
|
||||
announceIndex, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
|
||||
handlePrev, handleNext, handleGoto,
|
||||
};
|
||||
}
|
||||
@@ -635,7 +618,7 @@ type NoticeState = ReturnType<typeof useSystemNoticeModal>;
|
||||
|
||||
// Build the NoticeContent props for a given notice + pager slot index.
|
||||
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
||||
const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
||||
const rawBody = t(n.bodyKey);
|
||||
const body = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
@@ -652,7 +635,6 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number):
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
onSecondaryCTA: handleSecondaryCTA,
|
||||
@@ -823,13 +805,16 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
>
|
||||
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* The side slots are drag previews only: their pager and CTA are a
|
||||
second copy of the centre ones, so they stay out of the a11y tree
|
||||
and the tab order until they become the centre slot. */}
|
||||
<div ref={prevSlotRef} inert aria-hidden="true" style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{prevNotice && <NoticeContent {...makeContentProps(S, prevNotice, idx - 1)} />}
|
||||
</div>
|
||||
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<NoticeContent {...makeContentProps(S, notice, idx)} />
|
||||
</div>
|
||||
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div ref={nextSlotRef} inert aria-hidden="true" style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{nextNotice && <NoticeContent {...makeContentProps(S, nextNotice, idx + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user