Skip to content
Merged
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
20 changes: 10 additions & 10 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ App
└── AppRouter
└── Layout (root for all routes)
├── header
│ ├── title → PlanPicker on /plan when >1 plan exists, else plain text
│ ├── SearchInput
│ ├── show-completed checkbox
│ └── menu icon → PlanSettings (sidebar)
Expand All @@ -17,8 +18,7 @@ App
│ ├── / → ChapterGroupList (Books view)
│ │ └── ChapterGroup (one per book)
│ │ └── Chapter (one per chapter)
│ ├── /plan → PlanToggle (segmented control, hidden if only one plan)
│ │ + ChapterGroupList (Plan view)
│ ├── /plan → ChapterGroupList (Plan view)
│ │ └── ChapterGroup (one per day)
│ │ └── Chapter
│ └── /history → HistoryList
Expand All @@ -38,21 +38,19 @@ Configures routes. Computes two top-level data structures passed as props:
- `bookGroups: Record<BookName, ChapterData[]>` — computed once via `groupByBook`
- `planGroups: Accessor<Record<string, ChapterData[]>>` — `createMemo` around `groupByDay`; reactive to `api.perDayTagData()`

The `/plan` route renders `PlanToggle` above `ChapterGroupList`.

### `PlanToggle` (`PlanToggle.tsx`)

iOS-style segmented control for switching `api.activePlanId()` — one segment per `api.plans()` entry, tapping calls `api.setActivePlanId`. Renders nothing (`<Show when={api.plans().length > 1}>`) when there's only one plan, since a toggle with a single, permanently-selected option is pointless. Scrolls away with the page content rather than staying pinned — `ChapterGroup`'s own day headers are already `position: sticky; top: 0` inside the same scroll container (`Layout`'s `<main>`), and that component is shared across the Books/Plan/History routes, so pinning the toggle at the same `top: 0` would fight with — and visually sit on top of — the day headers once they reach the top of the scroll area.

### `Layout` (`Layout.tsx`)

Shell shared by all routes. Contains:

- Page title (derived from current path)
- Page title (derived from current path) — on `/plan`, replaced with `PlanPicker` once there's more than one plan to switch between
- Show Completed checkbox (toggles `api.showCompleted`)
- Search input (writes to `api.setSearchText`)
- Sidebar toggle (shows/hides `PlanSettings`)
- Tab bar navigation
- Tab bar navigation (Plan, Books, History, Settings)

### `PlanPicker` (`PlanPicker.tsx`)

iOS-style "tap the title to switch context" menu, the same pattern Mail uses for its inbox picker and Reminders for its list picker — chosen over a segmented control specifically because a segmented control runs out of horizontal room once there are more than a couple of plans. `Layout` only renders it (in place of the plain `<h1>` title text) when `api.plans().length > 1`; the trigger button shows `api.activePlan().name` plus a chevron and, when tapped, opens an absolutely-positioned menu (closed on outside `pointerdown` or on selecting an item) listing every `api.plans()` entry with a checkmark on the active one. Selecting an entry calls `api.setActivePlanId`.

### `ChapterGroupList` (`ChapterGroupList.tsx`)

Expand All @@ -62,6 +60,8 @@ Key behaviors:
- Filters groups by `api.searchText()` — only groups containing a matching chapter name are shown
- When `sortProgressToTop` is true (Books view), groups with any completed chapters are sorted above those with none

The `<For>` over group names looks up each group's chapters (`props.data[groupName]`) *inline* in the `ChapterGroup` `data` prop rather than pre-computing it into a local `const`. This matters specifically on the Plan view: the group names are always `"Day 1"`, `"Day 2"`, ... regardless of which plan is active, so `<For>` (keyed by value) never re-invokes its callback when you switch plans — a plain `const chapters = props.data[groupName]` computed once inside that callback would freeze at whichever plan was active on first render. Keeping the lookup inline lets it compile to a getter that's re-read reactively instead.

### `ChapterGroup` (`ChapterGroup.tsx`)

Accordion row representing one book (Books view) or one day (Plan view).
Expand Down
8 changes: 1 addition & 7 deletions web/src/components/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Layout } from './Layout'
import { ChapterGroupList } from './ChapterGroupList'
import { HistoryList } from './HistoryList'
import { PlanSettings } from './PlanSettings'
import { PlanToggle } from './PlanToggle'
import { getBookNamesMap, getChapterData } from '../utils/dataUtils'
import { groupByBook, groupByDay } from '../utils/groupUtils'
import { useApi } from './ApiContext'
Expand All @@ -29,12 +28,7 @@ export function AppRouter() {
/>
<Route
path="/plan"
component={() => (
<>
<PlanToggle />
<ChapterGroupList data={planGroups()} />
</>
)}
component={() => <ChapterGroupList data={planGroups()} />}
/>

<Route
Expand Down
25 changes: 11 additions & 14 deletions web/src/components/ChapterGroupList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,17 @@ export function ChapterGroupList(props: ChapterGroupListProps) {
<div class={styles.ChapterGroupList}>
<ul>
<For each={sortedGroupNames()}>
{(groupName) => {
const chapters = props.data[groupName]
return (
<li class={styles.group}>
<ChapterGroup
data={{
name: groupName,
chapters,
}}
searchTextUc={searchTextUc()}
/>
</li>
)
}}
{(groupName) => (
<li class={styles.group}>
<ChapterGroup
data={{
name: groupName,
chapters: props.data[groupName],
}}
searchTextUc={searchTextUc()}
/>
</li>
)}
</For>
</ul>
</div>
Expand Down
11 changes: 9 additions & 2 deletions web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useApi } from './ApiContext'
import styles from './Layout.module.css'
import { A, type RouteSectionProps } from '@solidjs/router'
import { Icon } from './Icon'
import { PlanPicker } from './PlanPicker'

const isIosSafari =
/iP(hone|ad|od)/.test(navigator.userAgent) &&
Expand All @@ -20,9 +21,11 @@ export function Layout(props: RouteSectionProps) {
)

const isSettingsRoute = () => props.location.pathname.endsWith('/settings')
const isPlanRoute = () => props.location.pathname.endsWith('/plan')
const showPlanPicker = () => isPlanRoute() && api.plans().length > 1

const title = () =>
props.location.pathname.endsWith('/plan')
isPlanRoute()
? 'Plan'
: props.location.pathname.endsWith('/history')
? 'History'
Expand All @@ -48,7 +51,11 @@ export function Layout(props: RouteSectionProps) {
</div>
</Show>
<header class={styles.header}>
<h1 class={styles.title}>{title()}</h1>
<h1 class={styles.title}>
<Show when={showPlanPicker()} fallback={title()}>
<PlanPicker />
</Show>
</h1>
<Show when={!isSettingsRoute()}>
<label class={styles.showCompleted}>
<input
Expand Down
83 changes: 83 additions & 0 deletions web/src/components/PlanPicker.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
.PlanPicker {
position: relative;
display: inline-block;
}

.trigger {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 100%;
background: none;
border: none;
font: inherit;
color: inherit;
padding: 0;
cursor: pointer;

&:active {
opacity: 0.7;
}
}

.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.chevron {
flex-shrink: 0;
width: 20px;
height: 20px;
}

.menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 0.6rem;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
width: max-content;
min-width: 180px;
max-width: min(80vw, 320px);
max-height: 320px;
overflow-y: auto;
z-index: 10;
color: #1c1c1e;
text-align: left;
}

.menuItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: var(--item-height);
padding: 0 var(--item-padding-h);
font-size: 1rem;
cursor: pointer;

&:hover {
background-color: var(--color-hover);
}

&[aria-checked='true'] {
font-weight: 600;
}

svg {
color: var(--color-accent);
flex-shrink: 0;
}
}

.menuItemName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
64 changes: 64 additions & 0 deletions web/src/components/PlanPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createSignal, For, onCleanup, Show } from 'solid-js'
import { useApi } from './ApiContext'
import { Icon } from './Icon'
import type { PlanId } from '../data/model'
import styles from './PlanPicker.module.css'

/**
* iOS-style "tap the title to switch context" menu — the pattern Mail uses for
* its inbox picker and Reminders uses for its list picker. Scales to any
* number of plans, unlike a segmented control which runs out of width fast.
* Caller is expected to only render this when there's more than one plan.
*/
export function PlanPicker() {
const api = useApi()
const [open, setOpen] = createSignal(false)
let ref: HTMLSpanElement | undefined

const close = () => setOpen(false)

const onDocPointerDown = (e: PointerEvent) => {
if (open() && ref && !ref.contains(e.target as Node)) close()
}
document.addEventListener('pointerdown', onDocPointerDown)
onCleanup(() => document.removeEventListener('pointerdown', onDocPointerDown))

const onSelect = (id: PlanId) => {
return () => {
api.setActivePlanId(id)
close()
}
}

return (
<span class={styles.PlanPicker} ref={ref}>
<button
type="button"
class={styles.trigger}
aria-haspopup="menu"
aria-expanded={open()}
onClick={() => setOpen((o) => !o)}>
<span class={styles.triggerLabel}>{api.activePlan().name}</span>
<Icon class={styles.chevron} name="chevron-down-sharp" />
</button>
<Show when={open()}>
<ul class={styles.menu} role="menu">
<For each={api.plans()}>
{(plan) => (
<li
role="menuitemradio"
aria-checked={plan.id === api.activePlanId()}
class={styles.menuItem}
onClick={onSelect(plan.id)}>
<span class={styles.menuItemName}>{plan.name}</span>
<Show when={plan.id === api.activePlanId()}>
<Icon name="checkmark-circle" />
</Show>
</li>
)}
</For>
</ul>
</Show>
</span>
)
}
42 changes: 0 additions & 42 deletions web/src/components/PlanToggle.module.css

This file was deleted.

33 changes: 0 additions & 33 deletions web/src/components/PlanToggle.tsx

This file was deleted.

Loading