- Phones get a bottom tab bar (Studies, Bible, Plans, Prayer, Account); wider screens keep the header nav - The Account menu opens upward from the bar; toasts sit above it - Reader and study pages get a labelled Home button instead of a small arrow Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9893883b19
commit
a2185ba1dd
10 files changed
+125
-16
No files matched your search
@@ -43,7 +43,7 @@ export function ToastProvider({ children }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px)+var(--bottom-nav,0px))] font-sans">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" onClick={onClick} aria-label="Back to home" data-tip="Back to home"
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-full border border-[var(--r-line)] px-3 text-sm text-[var(--r-text)] transition hover:bg-black/5">
|
||||
<HomeIcon /><span>Home</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||
<HomeButton onClick={onBack} />
|
||||
<button type="button" onClick={onOpenNavigator}
|
||||
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)">
|
||||
<span className="truncate">{title}</span><ChevronDown />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
|
||||
|
||||
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
|
||||
export const HomeIcon = () => <svg {...base}><path d="M3.5 9.5L10 4l6.5 5.5M5 8.5V16h3.5v-4h3v4H15V8.5" /></svg>;
|
||||
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
|
||||
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
|
||||
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
|
||||
import { useIsPhone } from '../../hooks/useIsPhone.js';
|
||||
|
||||
// `short` is what a phone shows; the full label is still the button's accessible name.
|
||||
const NAV = [
|
||||
@@ -13,8 +14,18 @@ const NAV = [
|
||||
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
|
||||
];
|
||||
|
||||
const ICONS = {
|
||||
studies: 'M4 5.5A1.5 1.5 0 0 1 5.5 4H18a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5.5ZM8 4v15M11 8h5M11 11.5h5',
|
||||
reader: 'M12 6.5C10.5 5 8 4.5 4.5 4.5V18c3.5 0 6 .5 7.5 2 1.5-1.5 4-2 7.5-2V4.5c-3.5 0-6 .5-7.5 2ZM12 6.5V20',
|
||||
plans: 'M5 6.5h14M5 6.5v12A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5v-12M5 6.5A1.5 1.5 0 0 1 6.5 5H8m11 1.5A1.5 1.5 0 0 0 17.5 5H16M8 3.5v3m8-3v3M9 12.5l2 2 4-4',
|
||||
prayer: 'M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z',
|
||||
};
|
||||
const TabIcon = ({ id }) => (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={ICONS[id]} /></svg>
|
||||
);
|
||||
|
||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||
function AccountMenu() {
|
||||
function AccountMenu({ tab = false, active = false }) {
|
||||
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
@@ -33,12 +44,20 @@ function AccountMenu() {
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
|
||||
{authUser?.email?.[0] ?? '·'}
|
||||
</button>
|
||||
{tab ? (
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||
className={`flex min-h-[52px] w-full flex-col items-center justify-center gap-0.5 text-[11px] ${open || active ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
|
||||
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-full border border-current text-[11px] font-medium uppercase">{authUser?.email?.[0] ?? '·'}</span>
|
||||
<span aria-hidden="true">Account</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
|
||||
{authUser?.email?.[0] ?? '·'}
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
|
||||
<div role="menu" className={`absolute z-40 w-60 ${tab ? 'bottom-full right-2 mb-2' : 'right-0 top-full mt-2'} rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl`}>
|
||||
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
|
||||
<div className="px-3 pb-2 pt-1">
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
|
||||
@@ -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 (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<a href="#content" className="skip-link">Skip to content</a>
|
||||
@@ -74,14 +100,14 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
<img src="/logo.svg" alt="" className="h-6 w-6" />
|
||||
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
||||
</button>
|
||||
<nav aria-label="Main" className="flex items-center gap-0.5">
|
||||
{!phone && <nav aria-label="Main" className="flex items-center gap-0.5">
|
||||
{NAV.map((n) => (
|
||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
||||
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</nav>}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{openPalette && (
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
|
||||
@@ -89,7 +115,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
|
||||
</button>
|
||||
)}
|
||||
<AccountMenu />
|
||||
{!phone && <AccountMenu />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -103,6 +129,21 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{phone && (
|
||||
<>
|
||||
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
|
||||
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
|
||||
{NAV.map((n) => (
|
||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
||||
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
|
||||
<TabIcon id={n.id} />
|
||||
<span aria-hidden="true" className={active === n.id ? 'font-medium' : ''}>{n.short}</span>
|
||||
</button>
|
||||
))}
|
||||
<AccountMenu tab active={['settings', 'admin'].includes(active)} />
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
|
||||
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(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
|
||||
const nav = screen.getByRole('navigation', { name: 'Main' });
|
||||
expect(nav.closest('header')).not.toBeNull();
|
||||
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1);
|
||||
});
|
||||
@@ -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
|
||||
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'fixed inset-0 flex flex-col overflow-hidden' : 'min-h-dvh'}`} style={themeVars(readerTheme)}>
|
||||
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
||||
<button type="button" onClick={goHome} aria-label="Back to home"
|
||||
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||
<HomeButton onClick={goHome} />
|
||||
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
|
||||
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
||||
{STAGES.map((s) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const QUERY = '(max-width: 639px)';
|
||||
const read = () => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(QUERY).matches : false);
|
||||
|
||||
// True on a phone-width screen (under Tailwind's `sm`). False where matchMedia is missing (tests), so the full
|
||||
// layout is what renders there.
|
||||
export function useIsPhone() {
|
||||
const [phone, setPhone] = useState(read);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return undefined;
|
||||
const mq = window.matchMedia(QUERY);
|
||||
const update = () => setPhone(mq.matches);
|
||||
update();
|
||||
mq.addEventListener?.('change', update);
|
||||
return () => mq.removeEventListener?.('change', update);
|
||||
}, []);
|
||||
return phone;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
|
||||
<PageShell title="Admin" active="admin" onBack={goHome} maxWidth="max-w-5xl">
|
||||
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAdminTab('users')}
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
|
||||
<PageShell title="Account Settings" active="settings" onBack={goHome} maxWidth="max-w-2xl">
|
||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
||||
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{SETTINGS_TABS.map(([id, label]) => (
|
||||
|
||||
Reference in new issue
Block a user