From b37fed5ca3a29abd12edcf34a74fa55e68066b3f Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 10:20:21 +0530 Subject: [PATCH 1/2] feat(plan): advance focus and flip phone suggestions --- src/App.tsx | 3 +++ src/ui/PlanView.tsx | 25 +++++++++++++++---- src/ui/useSuggestionPlacement.ts | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 src/ui/useSuggestionPlacement.ts diff --git a/src/App.tsx b/src/App.tsx index 687b32e..0e4cd88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -755,6 +755,9 @@ export function App() { dispatch({ type: "select", field, code }) setPlacedField(null) clearResult() + if (field === "from") { + requestAnimationFrame(() => document.getElementById("to")?.focus()) + } }} onHighlight={(index) => dispatch({ type: "highlight", index })} onSwap={() => { diff --git a/src/ui/PlanView.tsx b/src/ui/PlanView.tsx index 0dc5e14..40c4254 100644 --- a/src/ui/PlanView.tsx +++ b/src/ui/PlanView.tsx @@ -27,6 +27,7 @@ import { measureSwap, playSwap, type SwapDelta } from "./flipSwap" import { MetroScene } from "./MetroScene" import { Notice } from "./Notice" import { RouteBoard } from "./RouteBoard" +import { useSuggestionPlacement } from "./useSuggestionPlacement" type Field = "from" | "to" @@ -478,12 +479,15 @@ function StationField({ onHighlight: (index: number) => void closedCodes?: Set }) { + const inputRef = useRef(null) const active = state.activeField === field const listId = `${field}-suggestions` const query = field === "from" ? state.fromQuery : state.toQuery const pick = field === "from" ? state.from : state.to const stationLines = pick ? lineColors(pick.code, lines, lang) : [] const open = active && (state.suggestionStatus === "ready" || state.suggestionStatus === "empty" || state.suggestionStatus === "loading" || state.suggestionStatus === "error") + const listOpen = open && state.suggestions.length > 0 + const place = useSuggestionPlacement(listOpen, inputRef) function onKeyDown(event: KeyboardEvent) { if (!active) return @@ -501,8 +505,16 @@ function StationField({ } } + const fieldClass = [ + "field", + placed ? "field-placed" : "", + listOpen ? (place === "above" ? "is-list-above" : "is-list-below") : "", + ] + .filter(Boolean) + .join(" ") + return ( -
+
0} + aria-expanded={listOpen} aria-controls={listId} aria-activedescendant={ active && state.highlight >= 0 ? `${field}-option-${state.highlight}` : undefined } value={query} placeholder={copy.searchStation} - onFocus={() => onFocus(field)} + onFocus={(event) => { + onFocus(field) + const input = event.currentTarget + requestAnimationFrame(() => input.select()) + }} onChange={(event) => onType(field, event.target.value)} onKeyDown={onKeyDown} /> {open && state.suggestionStatus === "loading" ?

{copy.searching}

: null} {open && state.suggestionStatus === "empty" ?

{copy.noResults}

: null} {open && state.suggestionStatus === "error" ?

{copy.searchError}

: null} - {open && state.suggestions.length > 0 ? ( + {listOpen ? (
    {state.suggestions.map((station, index) => (
  • diff --git a/src/ui/useSuggestionPlacement.ts b/src/ui/useSuggestionPlacement.ts new file mode 100644 index 0000000..eff10ef --- /dev/null +++ b/src/ui/useSuggestionPlacement.ts @@ -0,0 +1,41 @@ +import { useLayoutEffect, useState, type RefObject } from "react" + +/** On narrow screens, place the list where more visual-viewport space remains. */ +export function useSuggestionPlacement( + open: boolean, + anchorRef: RefObject, +): "below" | "above" { + const [place, setPlace] = useState<"below" | "above">("below") + + useLayoutEffect(() => { + if (!open) { + setPlace("below") + return + } + + function measure() { + const el = anchorRef.current + if (!el) return + const rect = el.getBoundingClientRect() + const vv = window.visualViewport + const top = vv?.offsetTop ?? 0 + const height = vv?.height ?? window.innerHeight + const spaceAbove = Math.max(0, rect.top - top) + const spaceBelow = Math.max(0, top + height - rect.bottom) + setPlace(spaceBelow >= spaceAbove ? "below" : "above") + } + + measure() + const vv = window.visualViewport + vv?.addEventListener("resize", measure) + vv?.addEventListener("scroll", measure) + window.addEventListener("resize", measure) + return () => { + vv?.removeEventListener("resize", measure) + vv?.removeEventListener("scroll", measure) + window.removeEventListener("resize", measure) + } + }, [open, anchorRef]) + + return place +} From bcea69e434c69d789e70dc7689fa7bcfc0c61840 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 10:20:21 +0530 Subject: [PATCH 2/2] feat(status): clear looked station and tidy day dialog --- src/i18n/copy.ts | 2 + src/index.css | 137 +++++++++++++++++++++++++-------- src/lib/status/history.test.ts | 14 +++- src/lib/status/history.ts | 3 +- src/ui/StatusHistory.tsx | 25 ++---- src/ui/StatusView.test.tsx | 2 + src/ui/StatusView.tsx | 47 ++++++++--- 7 files changed, 167 insertions(+), 63 deletions(-) diff --git a/src/i18n/copy.ts b/src/i18n/copy.ts index aae7474..f28d5f4 100644 --- a/src/i18n/copy.ts +++ b/src/i18n/copy.ts @@ -196,6 +196,7 @@ export type Copy = { statusGetOff: string statusNeedStart: string statusChangeStop: string + statusClearStation: string statusReady: string statusFoot: (count: number, when: string) => string statusClosedLabel: string @@ -429,6 +430,7 @@ export const copy = { statusGetOff: "Get off here", statusNeedStart: "First choose where you start.", statusChangeStop: "Change", + statusClearStation: "Clear station", statusReady: "Both gates are open.", statusFoot: (count, when) => when ? `${count === 1 ? "1 station closed" : `${count} stations closed`} ยท ${when}` : `${count === 1 ? "1 station closed" : `${count} stations closed`}`, diff --git a/src/index.css b/src/index.css index 80fbb36..acc02e9 100644 --- a/src/index.css +++ b/src/index.css @@ -1214,6 +1214,7 @@ section:not(.ticket):not(.departures):not(.map-station):not(.status-service):not } .field { + position: relative; display: flex; flex-direction: column; gap: var(--space); @@ -4237,6 +4238,33 @@ summary { gap: 8px; } + /* Keep suggestions on screen when the soft keyboard eats half the viewport. */ + .field.is-list-below, + .field.is-list-above { + z-index: 5; + } + + .field.is-list-below ul[role="listbox"], + .field.is-list-above ul[role="listbox"] { + position: absolute; + left: 0; + right: 0; + z-index: 30; + max-height: min(42dvh, 16rem); + overflow-y: auto; + box-shadow: 0 12px 28px color-mix(in srgb, var(--ink) 16%, transparent); + } + + .field.is-list-below ul[role="listbox"] { + top: calc(100% + 4px); + bottom: auto; + } + + .field.is-list-above ul[role="listbox"] { + bottom: calc(100% + 4px); + top: auto; + } + .planner, .map-intro, .map-list, @@ -4832,6 +4860,34 @@ summary { gap: 4px; } +.status-look-tools { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.35rem; +} + +.status-look-clear { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.status-look-clear:hover, +.status-look-clear:focus-visible { + color: var(--ink); + background: color-mix(in srgb, var(--ink) 8%, transparent); + outline: none; +} + .status-look-name, .status-name { display: flex; @@ -4965,7 +5021,7 @@ summary { align-items: center; justify-content: center; min-height: 44px; - padding: 0 10px; + padding: 0 12px; background: var(--card); color: var(--ink); border: 1px solid var(--line); @@ -4974,14 +5030,28 @@ summary { font-weight: 600; } -.status-act:hover { +.status-act:hover, +.status-act:focus-visible { border-color: var(--ink); + outline: none; } .status-split .zoom-row button { min-height: 44px; } +.status-split .zoom-row button.ghost { + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); +} + +.status-split .zoom-row button.ghost:hover, +.status-split .zoom-row button.ghost:focus-visible { + border-color: var(--ink); + outline: none; +} + .status-row-main { min-width: 0; display: flex; @@ -5624,28 +5694,49 @@ summary { } .status-day-head { - position: static; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; + position: relative; + display: block; margin: 0 0 0.95rem; - padding: 0; + padding: 0 2.25rem; background: transparent; + text-align: center; +} + +.status-day-titles { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + +.status-day-titles .status-kicker, +.status-day-titles h3 { + margin: 0; + text-align: center; } .status-day-close { - flex: 0 0 auto; - width: 44px; - height: 44px; + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; display: grid; place-items: center; margin: 0; padding: 0; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--shade); + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.status-day-close:hover, +.status-day-close:focus-visible { color: var(--ink); + background: color-mix(in srgb, var(--ink) 8%, transparent); + outline: none; } .status-day-list { @@ -5682,27 +5773,11 @@ summary { color: var(--muted); } -.status-day-main { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.4rem 0.5rem; - min-width: 0; -} - .status-day-name { + min-width: 0; font-weight: 600; } -.status-day-dialog .line-dots { - gap: 4px; -} - -.status-day-dialog .line-dot { - width: 10px; - height: 10px; -} - .status-below > .status-service { width: min(36rem, 100%); max-width: 36rem; diff --git a/src/lib/status/history.test.ts b/src/lib/status/history.test.ts index b40b8ec..6e17fcb 100644 --- a/src/lib/status/history.test.ts +++ b/src/lib/status/history.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest" -import { chartSeries, filterHistory, historyRangeOptions, historyYears, readHistory } from "./history" +import { + chartSeries, + filterHistory, + formatHistoryDate, + historyRangeOptions, + historyYears, + readHistory, +} from "./history" const history = readHistory({ days: [ @@ -40,6 +47,11 @@ describe("status history", () => { ]) }) + it("formats chart dates short and dialog dates with the year", () => { + expect(formatHistoryDate("2026-10-10")).toBe("10 Oct") + expect(formatHistoryDate("2026-10-10", true)).toBe("10 Oct 2026") + }) + it("lists years newest first", () => { expect(historyYears(history)).toEqual([2026, 2025]) }) diff --git a/src/lib/status/history.ts b/src/lib/status/history.ts index 7631f82..112df6a 100644 --- a/src/lib/status/history.ts +++ b/src/lib/status/history.ts @@ -55,13 +55,14 @@ export function historyRangeOptions(history: StatusHistory, today = todayStamp() return options } -export function formatHistoryDate(stamp: string): string { +export function formatHistoryDate(stamp: string, withYear = false): string { const date = parseStamp(stamp) if (!date) return stamp return new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", day: "numeric", month: "short", + ...(withYear ? { year: "numeric" as const } : {}), }).format(date) } diff --git a/src/ui/StatusHistory.tsx b/src/ui/StatusHistory.tsx index 64fc34c..facf099 100644 --- a/src/ui/StatusHistory.tsx +++ b/src/ui/StatusHistory.tsx @@ -215,7 +215,7 @@ function StatusDayDialog({ onClose: () => void }) { const box = useRef(null) - const title = copy.statusHistoryDay(formatHistoryDate(day.date), day.count) + const title = copy.statusHistoryDay(formatHistoryDate(day.date, true), day.count) useEffect(() => { const previous = document.body.style.overflow @@ -242,12 +242,12 @@ function StatusDayDialog({ aria-labelledby="status-day-title" >
    -
    +

    {copy.statusHistoryTitle}

    {title}

    {day.stations.length === 0 ? ( @@ -255,18 +255,14 @@ function StatusDayDialog({ ) : (
      {day.stations.map((stop, index) => { - const lines = lineByCode.get(stop.code) ?? [] - const accent = lines[0]?.color + const accent = (lineByCode.get(stop.code) ?? [])[0]?.color return (
    1. {index + 1} - - - {stop.name} - + {stop.name}
    2. ) })} @@ -278,17 +274,6 @@ function StatusDayDialog({ ) } -function LineDots({ lines }: { lines: LineChip[] }) { - if (lines.length === 0) return null - return ( -
    {shownPack ? ( ) : null} - @@ -354,8 +369,11 @@ function StatusSearch({ onHighlight: (index: number) => void onSelect: (station: Suggestion) => void }) { + const inputRef = useRef(null) const listId = "status-suggestions" const shown = open && (suggestions.length > 0 || query.trim().length > 0) + const listOpen = shown && suggestions.length > 0 + const place = useSuggestionPlacement(listOpen, inputRef) function onKeyDown(event: KeyboardEvent) { if (!open) return @@ -373,25 +391,34 @@ function StatusSearch({ } } + const fieldClass = ["field", listOpen ? (place === "above" ? "is-list-above" : "is-list-below") : ""] + .filter(Boolean) + .join(" ") + return ( -
    +
    0} + aria-expanded={listOpen} aria-controls={listId} aria-activedescendant={highlight >= 0 ? `status-option-${highlight}` : undefined} value={query} placeholder={copy.searchStation} - onFocus={onFocus} + onFocus={(event) => { + onFocus() + const input = event.currentTarget + requestAnimationFrame(() => input.select()) + }} onChange={(event) => onType(event.target.value)} onKeyDown={onKeyDown} /> {shown && suggestions.length === 0 ?

    {copy.noResults}

    : null} - {shown && suggestions.length > 0 ? ( + {listOpen ? (
      {suggestions.map((station, index) => (