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
17 changes: 12 additions & 5 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ App
└── AppRouter
└── Layout (root for all routes)
├── header
│ ├── title → PlanPicker on /plan when >1 plan exists, else plain text
│ ├── title (plain text, always — never replaced by route content)
│ ├── SearchInput
│ ├── show-completed checkbox
│ └── menu icon → PlanSettings (sidebar)
Expand All @@ -18,7 +18,8 @@ App
│ ├── / → ChapterGroupList (Books view)
│ │ └── ChapterGroup (one per book)
│ │ └── Chapter (one per chapter)
│ ├── /plan → ChapterGroupList (Plan view)
│ ├── /plan → PlanPicker (chip + dropdown, hidden if only one plan)
│ │ + ChapterGroupList (Plan view)
│ │ └── ChapterGroup (one per day)
│ │ └── Chapter
│ └── /history → HistoryList
Expand All @@ -38,21 +39,27 @@ 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 `PlanPicker` above `ChapterGroupList`.

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

Shell shared by all routes. Contains:

- Page title (derived from current path) — on `/plan`, replaced with `PlanPicker` once there's more than one plan to switch between
- Page title (derived from current path) — always plain generic text ("Plan", "Books", "History", "Settings"); route content never repurposes it, by design (see `PlanPicker` below)
- Show Completed checkbox (toggles `api.showCompleted`)
- Search input (writes to `api.setSearchText`)
- Sidebar toggle (shows/hides `PlanSettings`)
- 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`.
A secondary "current plan" chip rendered above the day list on `/plan` — tapping it opens a checkmarked dropdown menu listing every `api.plans()` entry; selecting one calls `api.setActivePlanId`. Renders nothing (`<Show when={api.plans().length > 1}>`) when there's only one plan.

This went through two other designs first, both rejected on user feedback after being built and tried:
1. A segmented control (one button per plan) — doesn't scale, runs out of horizontal room past a couple of plans.
2. The chip's current name+chevron content shown *in the page title itself* (replacing "Plan" with e.g. "My Plan ▾") — scaled fine, but the user specifically preferred that a plan switcher read as a distinct secondary control rather than take over the static title. (That version also needed `Layout`'s header grid to reserve space so the picker's trigger wouldn't overlap the Completed checkbox — that grid change is reverted along with it.)

`Layout`'s `.header` is a `1fr auto` grid (title column, then the Completed-checkbox column) and normally lets `.title` span both columns so short static titles (Books/History/Settings/"Plan") stay dead-centered in the full header width — the checkbox sits in its own column but `.title` freely overlaps that space since centered short text never reaches it. `PlanPicker`'s trigger can be much wider (a plan name plus a chevron), so `Layout` adds `styles.titleConstrained` to the `<h1>` only while the picker is showing, confining it to the `1fr` column instead of overlapping the checkbox's column. This is deliberately conditional — the same confinement applied unconditionally would needlessly truncate even short plan names, since it shrinks the box regardless of whether the content actually needs the room (verified: an earlier symmetric-padding attempt truncated even `"My Plan"`).
The current design keeps the title untouched and gives the chip its own full-width bar instead (`padding: 0.6rem var(--item-padding-h); background-color: var(--color-page);`, mirroring the segmented control's old wrapper), which incidentally also gives it much more room than either previous design for long plan names before `text-overflow: ellipsis` kicks in, since it isn't sharing a row with the Completed checkbox at all. It scrolls away with the page content rather than staying pinned, for the same reason the segmented control did: `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 chip bar at the same offset would visually collide with the day headers once you scroll.

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

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

<Route
Expand Down
10 changes: 0 additions & 10 deletions web/src/components/Layout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
border-bottom: 1px solid var(--color-border);
align-items: center;
grid-template-columns: 1fr auto;
column-gap: 0.75rem;
padding: calc(20px + env(safe-area-inset-top)) 1rem 20px;
}
.title {
Expand All @@ -20,15 +19,6 @@
line-height: 36px;
margin: 0;
text-align: center;
min-width: 0;
}
/* When a control shares row 1 with the title (e.g. the plan picker's chevron
needs room next to the Completed checkbox), confine the title to the
flexible first column instead of overlapping the checkbox's column — a
plain short title (Books/History/Settings) never needs this, so it's only
applied where there's an actual competing control. */
.titleConstrained {
grid-column: 1;
}
.showCompleted {
grid-column: 2;
Expand Down
12 changes: 2 additions & 10 deletions web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ 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'
import { className } from '../utils/cssUtils'

const isIosSafari =
/iP(hone|ad|od)/.test(navigator.userAgent) &&
Expand All @@ -22,11 +20,9 @@ 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 = () =>
isPlanRoute()
props.location.pathname.endsWith('/plan')
? 'Plan'
: props.location.pathname.endsWith('/history')
? 'History'
Expand All @@ -52,11 +48,7 @@ export function Layout(props: RouteSectionProps) {
</div>
</Show>
<header class={styles.header}>
<h1 class={className(styles.title, showPlanPicker() && styles.titleConstrained)}>
<Show when={showPlanPicker()} fallback={title()}>
<PlanPicker />
</Show>
</h1>
<h1 class={styles.title}>{title()}</h1>
<Show when={!isSettingsRoute()}>
<label class={styles.showCompleted}>
<input
Expand Down
34 changes: 17 additions & 17 deletions web/src/components/PlanPicker.module.css
Original file line number Diff line number Diff line change
@@ -1,56 +1,56 @@
.PlanPicker {
position: relative;
display: inline-block;
max-width: 100%;
background-color: var(--color-page);
padding: 0.6rem var(--item-padding-h);
}

.trigger {
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
gap: 0.4rem;
max-width: 100%;
background: none;
border: none;
font: inherit;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.45rem 0.9rem;
font-size: 0.95rem;
font-weight: 500;
color: inherit;
padding: 0;
cursor: pointer;

&:active {
opacity: 0.7;
}
}

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

.chevron {
flex-shrink: 0;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
color: var(--color-muted);
}

.menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 0.6rem;
left: var(--item-padding-h);
margin-top: 0.4rem;
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);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
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 {
Expand Down
71 changes: 37 additions & 34 deletions web/src/components/PlanPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ 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.
* Secondary "current plan" chip shown above the day list — tapping it opens a
* checkmarked dropdown menu to switch plans. Scales to any number of plans,
* unlike a segmented control, while — unlike putting the picker in the page
* title — leaving the "Plan" title alone as a distinct, secondary control.
* Renders nothing when there's only one plan to switch to.
*/
export function PlanPicker() {
const api = useApi()
const [open, setOpen] = createSignal(false)
let ref: HTMLSpanElement | undefined
let ref: HTMLDivElement | undefined

const close = () => setOpen(false)

Expand All @@ -31,34 +32,36 @@ export function PlanPicker() {
}

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>
<Show when={api.plans().length > 1}>
<div class={styles.PlanPicker} ref={ref}>
<button
type="button"
class={styles.chip}
aria-haspopup="menu"
aria-expanded={open()}
onClick={() => setOpen((o) => !o)}>
<span class={styles.chipLabel}>{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>
</div>
</Show>
)
}
Loading