Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 46 additions & 27 deletions apps/zoom-app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,64 @@ import ContactCapture from './components/ContactCapture';
import ZoomConnectionNotice from './components/ZoomConnectionNotice';
import ClubStatusBanner from './components/ClubStatusBanner';
import AnnouncementBanner from './components/AnnouncementBanner';
import { useNewLook } from './hooks/useNewLook';
import './App.css';

const LiveTab = lazy(() => import('./components/LiveTab'));
const AgendaTab = lazy(() => import('./components/AgendaTab'));
const ReportTab = lazy(() => import('./components/ReportTab'));
// Design B. Its own chunk, so today's panel does not ship the new one's code.
const NewLookShell = lazy(() => import('./b/NewLookShell'));

function App() {
/** The panel shipped today: tabs, the Live tab, the footer. */
export function ClassicShell() {
const [activeTab, setActiveTab] = useState('live');

return (
<div className="app-container w-full h-screen flex flex-col bg-white">
<ZoomConnectionNotice />
{/* Above the tabs, ahead of everything: the week of warning a club
gets before it loses Pro has to be seen by whoever is timing, who
is rarely whoever pays. */}
<ClubStatusBanner />
<AnnouncementBanner />
<NavTabs activeTab={activeTab} onTabChange={setActiveTab} />

<Suspense fallback={
<div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100%',flex:1}}>
<div style={{width:32,height:32,border:'3px solid #e5e7eb',borderTopColor:'#3b82f6',borderRadius:'50%',animation:'spin 0.6s linear infinite'}} />
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
</div>
}>
<div className="flex-1 overflow-y-auto">
{activeTab === 'live' && <LiveTab />}
{activeTab === 'agenda' && <AgendaTab onSwitchToLive={() => setActiveTab('live')} />}
{activeTab === 'report' && <ReportTab />}
</div>
</Suspense>

<Footer />
<PeriodicPrompts />
<TimerSounds />
<ContactCapture />
</div>
);
}

function App() {
// Behind the `new_look` release flag until design B is finished and launched.
const newLook = useNewLook();

return (
<ToastProvider>
<TimerProvider>
<div className="app-container w-full h-screen flex flex-col bg-white">
<ZoomConnectionNotice />
{/* Above the tabs, ahead of everything: the week of warning a club
gets before it loses Pro has to be seen by whoever is timing, who
is rarely whoever pays. */}
<ClubStatusBanner />
<AnnouncementBanner />
<NavTabs activeTab={activeTab} onTabChange={setActiveTab} />

<Suspense fallback={
<div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100%',flex:1}}>
<div style={{width:32,height:32,border:'3px solid #e5e7eb',borderTopColor:'#3b82f6',borderRadius:'50%',animation:'spin 0.6s linear infinite'}} />
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
</div>
}>
<div className="flex-1 overflow-y-auto">
{activeTab === 'live' && <LiveTab />}
{activeTab === 'agenda' && <AgendaTab onSwitchToLive={() => setActiveTab('live')} />}
{activeTab === 'report' && <ReportTab />}
</div>
{newLook ? (
<Suspense fallback={null}>
<NewLookShell />
</Suspense>

<Footer />
<PeriodicPrompts />
<TimerSounds />
<ContactCapture />
</div>
) : (
<ClassicShell />
)}
</TimerProvider>
</ToastProvider>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/zoom-app/src/b/NewLookShell.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ClassicShell } from '../App';

/**
* Design B's panel: the top bar with the Agenda / Timer / Report switch, the
* Pro chip and the ⋯ More menu, the timer card and the "Look & sound" sheets.
*
* Phase 0 of plan 13 only puts the `new_look` switch in place, so until the
* Phase 1 shell lands this is today's panel unchanged. The flag is off
* everywhere, so nobody sees the difference either way.
*/
export default function NewLookShell() {
return <ClassicShell />;
}
111 changes: 111 additions & 0 deletions apps/zoom-app/src/b/Sheet.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useId, useRef } from 'react';
import { X } from 'lucide-react';

const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

/**
* A bottom sheet: design B's one way of opening a setting (plan 13). The panel
* dims, the sheet rises from the bottom edge with a title and a close button,
* and only that one setting is inside it.
*
* A modal dialog to assistive tech and the keyboard: focus moves in when it
* opens and goes back to whatever opened it when it closes, Tab stays inside,
* Escape and a press on the dimmed panel close it.
*
* @param {{
* open: boolean,
* onClose: () => void,
* title: string,
* description?: import('react').ReactNode,
* badge?: import('react').ReactNode,
* children: import('react').ReactNode,
* }} props - `badge` sits beside the title (the PRO tag on Sounds)
*/
export default function Sheet({ open, onClose, title, description, badge, children }) {
const titleId = useId();
const sheetRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;

useEffect(() => {
if (!open) return undefined;
const opener = document.activeElement;
const sheet = sheetRef.current;
const first = sheet?.querySelector(FOCUSABLE);
(first || sheet)?.focus();

const handleKeyDown = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key !== 'Tab' || !sheet) return;
const focusable = [...sheet.querySelectorAll(FOCUSABLE)];
if (focusable.length === 0) {
event.preventDefault();
return;
}
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === firstEl) {
event.preventDefault();
lastEl.focus();
} else if (!event.shiftKey && document.activeElement === lastEl) {
event.preventDefault();
firstEl.focus();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (opener && typeof opener.focus === 'function' && document.contains(opener)) opener.focus();
};
}, [open]);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex flex-col justify-end">
{/* The dimmed panel. A press here closes, like the close button; it is
not a control of its own, so it stays out of the tab order. */}
<div
data-testid="sheet-backdrop"
aria-hidden="true"
className="absolute inset-0 bg-slate-900/45"
onClick={() => onCloseRef.current()}
/>
<section
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="relative flex max-h-[88vh] flex-col rounded-t-[18px] bg-white text-gray-900 shadow-[0_-8px_24px_rgba(0,0,0,0.18)] outline-none"
>
<div className="flex justify-center pt-2" aria-hidden="true">
<span className="h-1 w-10 rounded-full bg-slate-300" />
</div>
<div className="flex items-center gap-2 px-4 pt-2 pb-0.5">
<h2 id={titleId} className="m-0 text-[17px] font-semibold">
{title}
</h2>
{badge}
<span className="flex-1" />
<button
type="button"
onClick={() => onCloseRef.current()}
aria-label="Close"
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
{description && <p className="mx-4 mb-3 mt-0 text-[13px] text-slate-500">{description}</p>}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
</section>
</div>
);
}
97 changes: 97 additions & 0 deletions apps/zoom-app/src/b/Sheet.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import '@testing-library/jest-dom';
import { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Sheet from './Sheet';

function Harness({ onClose = () => {}, children }) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Cards
</button>
<Sheet
open={open}
onClose={() => {
onClose();
setOpen(false);
}}
title="Cards"
description="The card the timer shows at each colour."
>
{children ?? (
<>
<button type="button">Classic</button>
<button type="button">Modern</button>
</>
)}
</Sheet>
</>
);
}

const openSheet = () => fireEvent.click(screen.getByRole('button', { name: 'Cards' }));

describe('Sheet', () => {
it('renders nothing while closed', () => {
render(<Harness />);
expect(screen.queryByRole('dialog')).toBeNull();
});

it('is a labelled modal dialog with its description', () => {
render(<Harness />);
openSheet();
const dialog = screen.getByRole('dialog', { name: 'Cards' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(screen.getByText('The card the timer shows at each colour.')).toBeInTheDocument();
});

it('moves focus in when it opens, and back to the opener when it closes', () => {
render(<Harness />);
const opener = screen.getByRole('button', { name: 'Cards' });
opener.focus();
openSheet();
expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();

fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
expect(opener).toHaveFocus();
});

it('closes on Escape, on the close button and on the dimmed panel', () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} />);

openSheet();
fireEvent.keyDown(document, { key: 'Escape' });
openSheet();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
openSheet();
fireEvent.click(screen.getByTestId('sheet-backdrop'));

expect(onClose).toHaveBeenCalledTimes(3);
expect(screen.queryByRole('dialog')).toBeNull();
});

it('keeps Tab inside the sheet', () => {
render(<Harness />);
openSheet();
const close = screen.getByRole('button', { name: 'Close' });
const last = screen.getByRole('button', { name: 'Modern' });

last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(close).toHaveFocus();

fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});

it('does not close for a press inside the sheet', () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} />);
openSheet();
fireEvent.click(screen.getByRole('button', { name: 'Classic' }));
expect(onClose).not.toHaveBeenCalled();
});
});
Loading
Loading