diff --git a/src/components/Toasts.jsx b/src/components/Toasts.jsx index 572e70b..1bf872f 100644 --- a/src/components/Toasts.jsx +++ b/src/components/Toasts.jsx @@ -43,7 +43,7 @@ export function ToastProvider({ children }) { return ( {children} -
+
{toasts.map((t) => (
diff --git a/src/components/reader/HomeButton.jsx b/src/components/reader/HomeButton.jsx new file mode 100644 index 0000000..06d93e3 --- /dev/null +++ b/src/components/reader/HomeButton.jsx @@ -0,0 +1,11 @@ +import { HomeIcon } from './icons.jsx'; + +// The way back to the dashboard from the Reader and from a study. Always labelled, and big enough to hit with a thumb. +export default function HomeButton({ onClick }) { + return ( + + ); +} diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx index f4741ed..86af8d8 100644 --- a/src/components/reader/ReaderBars.jsx +++ b/src/components/reader/ReaderBars.jsx @@ -1,6 +1,7 @@ // Top and bottom chrome for the reader. Everything stays one tap away; focus // mode hides both. import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx'; +import HomeButton from './HomeButton.jsx'; import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; @@ -9,7 +10,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, return (
- + + {tab ? ( + + ) : ( + + )} {open && ( -
+
{authUser &&

{authUser.email}

}

Theme

@@ -65,6 +84,13 @@ function AccountMenu() { export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) { const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp(); const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer }; + const phone = useIsPhone(); + // On a phone the destinations sit in a bar at the bottom, where a thumb reaches; toasts lift above it. + useEffect(() => { + if (!phone) return undefined; + document.documentElement.style.setProperty('--bottom-nav', '56px'); + return () => document.documentElement.style.removeProperty('--bottom-nav'); + }, [phone]); return (
Skip to content @@ -74,14 +100,14 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi Bible Study - }
{openPalette && ( )} - + {!phone && }
@@ -103,6 +129,21 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
{children}
+ {phone && ( + <> + ); } diff --git a/src/components/study/PageShell.phone.test.jsx b/src/components/study/PageShell.phone.test.jsx new file mode 100644 index 0000000..17de769 --- /dev/null +++ b/src/components/study/PageShell.phone.test.jsx @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const app = { + readerTheme: 'paper', authUser: { email: 'sam@x.test' }, setReaderTheme: vi.fn(), setCurrentPage: vi.fn(), logout: vi.fn(), + goHome: vi.fn(), goStudies: vi.fn(), openBibleReader: vi.fn(), openReadingPlans: vi.fn(), openPrayer: vi.fn(), openPalette: vi.fn(), +}; +vi.mock('../../context/AppContext.js', () => ({ useApp: () => app })); +import PageShell from './PageShell.jsx'; + +const phone = (on) => vi.stubGlobal('matchMedia', (q) => ({ matches: on && q.includes('639'), media: q, addEventListener() {}, removeEventListener() {} })); +beforeEach(() => Object.values(app).forEach((f) => f.mockClear?.())); +afterEach(() => vi.unstubAllGlobals()); + +test('on a phone the destinations are a bar at the bottom, with Account in it', async () => { + phone(true); + const user = userEvent.setup(); + render(

content

); + const nav = screen.getByRole('navigation', { name: 'Main' }); + expect(within(nav).getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(['Studies', 'Read Bible', 'Reading Plans', 'Prayer', 'Account menu']); + expect(within(nav).getByRole('button', { name: 'Prayer' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1); // not also in the header + await user.click(within(nav).getByRole('button', { name: 'Reading Plans' })); + expect(app.openReadingPlans).toHaveBeenCalled(); + await user.click(within(nav).getByRole('button', { name: 'Account menu' })); + await user.click(screen.getByRole('menuitem', { name: 'Settings' })); + expect(app.setCurrentPage).toHaveBeenCalledWith('settings'); +}); + +test('on a wide screen the destinations stay in the header', () => { + phone(false); + render(

content

); + const nav = screen.getByRole('navigation', { name: 'Main' }); + expect(nav.closest('header')).not.toBeNull(); + expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1); +}); diff --git a/src/components/study/StudioShell.jsx b/src/components/study/StudioShell.jsx index 496b0bb..744597f 100644 --- a/src/components/study/StudioShell.jsx +++ b/src/components/study/StudioShell.jsx @@ -3,7 +3,7 @@ // the stage switcher, and save/sync status. import { useApp } from '../../context/AppContext.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; -import { ChevronLeft } from '../reader/icons.jsx'; +import HomeButton from '../reader/HomeButton.jsx'; export const STAGES = [ { id: 'setup', label: 'Chunks' }, @@ -27,8 +27,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
- +
{project?.title ?? ''}