From 0ae09615dd237459199e1b0ab5f52bda3416833b Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:27:05 +0900 Subject: [PATCH 1/3] A11y: title every control on the map, places and add-place screens The control-title guard still allowlisted App.tsx, the map, visits and the place picker pending a branch that has since merged, so 49 controls there carried no tooltip. Each now takes its visible label or aria-label as its title, the Trips layer and the gallery's Add button keep one when their conditional title fell through to undefined, and the allowlist is gone. --- apps/postcards/src/app/App.tsx | 2 +- apps/postcards/src/features/map/MapScreen.tsx | 14 ++++++- apps/postcards/src/features/map/MapView.tsx | 1 + .../src/features/travel/PlacePicker.tsx | 3 ++ .../src/features/visits/AddPlaceForm.tsx | 15 ++++++- .../src/features/visits/PhotoGallery.tsx | 20 +++++++++- .../src/features/visits/PlaceSearch.tsx | 11 +++--- .../src/features/visits/PlacesScreen.tsx | 39 ++++++++++++++++--- .../src/features/visits/StateToggles.tsx | 10 ++--- .../tests/unit/controlTitles.spec.ts | 13 +------ 10 files changed, 93 insertions(+), 35 deletions(-) diff --git a/apps/postcards/src/app/App.tsx b/apps/postcards/src/app/App.tsx index 4d0cfe7..4333f15 100644 --- a/apps/postcards/src/app/App.tsx +++ b/apps/postcards/src/app/App.tsx @@ -345,7 +345,7 @@ export function App() { return (
- + {t("app.skipToContent")} diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index 66cc162..c88fea9 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -729,6 +729,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { @@ -903,7 +908,12 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { ๐Ÿ—บ {t("map.layer.myCountries")} {onlineMap && basemapCycle.length > 1 && ( - )} diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index 70e7f3e..b60c6bb 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -2205,6 +2205,7 @@ export function MapView({ -
diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 440295a..6f397ec 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -171,6 +171,7 @@ export function PlaceSearch({ type="search" className={"search-input" + (q ? " has-clear" : "")} placeholder={narrow ? t("search.placeholderShort") : t("search.placeholder")} + title={t("search.aria")} aria-label={t("search.aria")} role="combobox" aria-expanded={results.length > 0} @@ -216,6 +217,9 @@ export function PlaceSearch({ > {results.map((r, i) => { const visited = visitIndex(visits).get(placeKey(r.place))?.status === "visited"; + const addLabel = visited + ? t("states.removeFromVisited", { name: r.place.name }) + : t("places.row.markVisitedAria", { name: r.place.name }); return (
  • toggle(r.place)} > {visited ? `โœ“ ${t("places.country.visitedChip")}` : `๏ผ‹ ${t("search.addChip")}`} diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index e2aa428..d29a885 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -246,6 +246,7 @@ function RowMenu({ setDate(e.target.value)} /> @@ -255,6 +256,7 @@ function RowMenu({ {t("places.rowMenu.folder")}
    - - {/* Remove lives here now (not inline) so the row itself stays uncluttered @@ -289,6 +292,7 @@ function RowMenu({ )} @@ -873,6 +882,7 @@ export function PlacesScreen() { type="button" aria-pressed={collection === c} className={collection === c ? "seg-on" : ""} + title={t(`places.collection.${c}` as const)} onClick={() => selectCollection(c)} > {c === "moments" ? "โœจ" : c === "photos" ? "๐Ÿ“ท" : "๐Ÿ›‚"}{" "} @@ -898,6 +908,7 @@ export function PlacesScreen() { type="button" aria-pressed={!collection && kind === k} className={!collection && kind === k ? "seg-on" : ""} + title={kindLabel(k)} onClick={() => selectKind(k)} > {kindLabel(k)} @@ -916,6 +927,7 @@ export function PlacesScreen() { type="button" aria-pressed={!collection && status === s} className={!collection && status === s ? "seg-on" : ""} + title={t(`places.status.${s}` as const)} onClick={() => selectStatus(s)} > {t(`places.status.${s}` as const)} @@ -931,6 +943,7 @@ export function PlacesScreen() { type="search" className="search-input places-filter has-clear" placeholder={searchPlaceholder} + title={searchAria} aria-label={searchAria} value={filter} onChange={(e) => setFilter(e.target.value)} @@ -959,6 +972,7 @@ export function PlacesScreen() { className={"chip filter-open-chip" + (placesFilterActive ? " chip-on" : "")} aria-haspopup="dialog" aria-expanded={filterOpen} + title={t("filter.open")} aria-label={ placesFilterActive ? `${t("filter.open")} ยท ${t("filter.activeAria", { count: placesFilterChips.length })}` @@ -979,6 +993,7 @@ export function PlacesScreen() { type="button" className={"mini-btn" + (groupBy === g ? " mini-on" : "")} aria-pressed={groupBy === g} + title={t(`places.groupBy.${g}` as const)} onClick={() => setGroupBy(g)} > {t(`places.groupBy.${g}` as const)} @@ -1000,6 +1015,7 @@ export function PlacesScreen() { type="button" aria-pressed={filters.category === cat} className={filters.category === cat ? "seg-on" : ""} + title={t(`filter.category.${cat || "all"}` as const)} onClick={() => filters.set({ category: cat })} > {t(`filter.category.${cat || "all"}` as const)} @@ -1020,7 +1036,12 @@ export function PlacesScreen() { ๐Ÿงญ {t("places.all.notVisitedHint")}{" "} -

    @@ -1041,7 +1062,7 @@ export function PlacesScreen() { className="journal-place-group" open={personalGroups.length <= 6} > - + {grp.flag ? `${grp.flag} ` : ""} {grp.label} @@ -1097,7 +1118,12 @@ export function PlacesScreen() {

    {t("places.browse.emptyStatus")}{" "} {status !== "all" && ( - )} @@ -1139,6 +1165,7 @@ export function PlacesScreen() { type="search" className="search-input has-clear" placeholder={t("places.countries.filterPlaceholder")} + title={t("places.countries.filterAria")} aria-label={t("places.countries.filterAria")} value={filter} onChange={(e) => setFilter(e.target.value)} diff --git a/apps/postcards/src/features/visits/StateToggles.tsx b/apps/postcards/src/features/visits/StateToggles.tsx index 53229d2..04a717f 100644 --- a/apps/postcards/src/features/visits/StateToggles.tsx +++ b/apps/postcards/src/features/visits/StateToggles.tsx @@ -74,6 +74,9 @@ export function StateToggles({ // record) suppresses all three buttons โ€” don't render an empty, labelled group // (a dead screen-reader stop + a gap next to its "โœ“ Visited" chip). if (!showBeen && !showWant && !showFav) return null; + const beenLabel = been + ? t("states.removeFromVisited", { name: place.name }) + : t("places.row.markVisitedAria", { name: place.name }); return (

    @@ -82,11 +85,8 @@ export function StateToggles({ className={"state been" + (been ? " on" : "")} type="button" aria-pressed={been} - aria-label={ - been - ? t("states.removeFromVisited", { name: place.name }) - : t("places.row.markVisitedAria", { name: place.name }) - } + aria-label={beenLabel} + title={beenLabel} onClick={onBeen} > โœ“ diff --git a/apps/postcards/tests/unit/controlTitles.spec.ts b/apps/postcards/tests/unit/controlTitles.spec.ts index 34ad956..6097691 100644 --- a/apps/postcards/tests/unit/controlTitles.spec.ts +++ b/apps/postcards/tests/unit/controlTitles.spec.ts @@ -9,15 +9,6 @@ const ROOT = process.cwd(); // Roles that make any element a control someone operates. const CONTROL_ROLES = new Set(["button", "tab", "switch", "checkbox", "menuitem", "radio"]); -// Files whose controls get their titles with the rewrite in flight there, -// pending the ux-flags-map merge. -const PENDING = [ - "src/app/App.tsx", - "src/features/map/", - "src/features/visits/", - "src/features/travel/PlacePicker.tsx", -]; - function tsxFiles(dir: string, acc: string[] = []): string[] { for (const name of readdirSync(dir)) { const path = join(dir, name); @@ -76,9 +67,7 @@ describe("interactive controls", () => { // Repo rule: every control a pointer or keyboard reaches carries a title, the // tooltip that names it for a mouse user. A spread may carry one, so it passes. it("each carry a title", () => { - const missing = tsxFiles(join(ROOT, "src")) - .flatMap(untitled) - .filter((hit) => !PENDING.some((p) => hit.startsWith(p))); + const missing = tsxFiles(join(ROOT, "src")).flatMap(untitled); expect(missing).toEqual([]); }); }); From 45effbd71032a15ba2eec7f50f03443c7daba4cf Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:36:31 +0900 Subject: [PATCH 2/3] Search: let a screen reader reach both a result and its Add chip Axe flagged nested-interactive on every search result: the Add or Visited chip sat inside the role=option, and an option's children are presentational, so assistive technology announced the place and never the chip. The results are now a grid, one row per place with a cell holding the place's button and a cell holding the chip; the combobox keeps its arrow keys, Enter, Shift+Enter and Escape through aria-activedescendant on the place's cell. The axe gate now opens the search results in light, dark and 375 px. --- .../src/features/visits/PlaceSearch.tsx | 81 ++++++++++--------- apps/postcards/src/styles.css | 17 ++-- apps/postcards/tests/e2e/a11y.spec.ts | 10 ++- apps/postcards/tests/e2e/flags.spec.ts | 4 +- apps/postcards/tests/e2e/nav-helper.ts | 2 +- 5 files changed, 65 insertions(+), 49 deletions(-) diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 6f397ec..abf465c 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -16,7 +16,10 @@ import { SearchIcon } from "../../ui/icons"; * city/airport/monument or opens a country's page โ€” and never logs anything by * itself (an accidental Enter used to silently mark the top match visited). * Marking visited is the explicit "Add" chip on the row, or Shift+Enter. - * Fully keyboard-operable: arrows move the active option, Escape clears. + * Fully keyboard-operable: arrows move the active result, Escape clears. + * The results are a grid, one row per place with a cell for the place and one + * for its Add chip: an option may hold no control, so a listbox would hide the + * chip from a screen reader. */ export function PlaceSearch({ onFocusCity, @@ -32,7 +35,7 @@ export function PlaceSearch({ const [q, setQ] = useState(""); const [active, setActive] = useState(-1); const inputRef = useRef(null); - const listRef = useRef(null); + const listRef = useRef(null); const focusNonce = useUi((s) => s.searchFocusNonce); // On a phone the top-bar field is narrow (it shares the row with the brand and @@ -174,6 +177,7 @@ export function PlaceSearch({ title={t("search.aria")} aria-label={t("search.aria")} role="combobox" + aria-haspopup="grid" aria-expanded={results.length > 0} aria-controls="search-results" aria-autocomplete="list" @@ -208,11 +212,11 @@ export function PlaceSearch({ : ""}

    {results.length > 0 && ( -
      {results.map((r, i) => { @@ -221,49 +225,48 @@ export function PlaceSearch({ ? t("states.removeFromVisited", { name: r.place.name }) : t("places.row.markVisitedAria", { name: r.place.name }); return ( -
    • - - {/* Logging is ITS OWN button โ€” showing a place never logs it. - Countries have none: they're visited via places inside. */} - {r.place.kind !== "country" && ( +
      +
      +
      + {/* Logging is ITS OWN button โ€” showing a place never logs it. + Countries have none: they're visited via places inside. */} + {r.place.kind !== "country" && ( +
      + +
      )} -
    • +
    ); })} - +
    )} {/* No "add your own place" here: a custom place is added by tapping its spot on the map (long-press / "Add a place here"), which is where coordinates diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 85e8ba7..5664673 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -703,7 +703,8 @@ h1.brand { max-height: 320px; overflow-y: auto; } -.results li button { +.results li button, +.results div button { width: 100%; display: flex; align-items: center; @@ -717,22 +718,25 @@ h1.brand { color: var(--text); } .results li button:hover, -.results li button:focus-visible { +.results li button:focus-visible, +.results div button:hover, +.results div button:focus-visible { background: var(--bg-sub); } -/* The global search splits each option into TWO buttons โ€” the row shows the +/* The global search splits each row into TWO cells โ€” the first shows the place, the trailing chip logs it โ€” so browsing can never accidentally mark something visited. (The Trips PlacePicker keeps single-button rows.) */ -.results-split li { +.results-split [role="row"] { display: flex; align-items: center; gap: 6px; padding-right: 6px; } -.results-split li .result-open { +.results-split [role="gridcell"]:first-child { flex: 1; min-width: 0; - width: auto; +} +.results-split .result-open { justify-content: flex-start; } /* The country's flag leads each option, as in every other place list. */ @@ -2427,6 +2431,7 @@ a.btn-ghost { /* Fit-to-my-places button over the map */ /* Active (keyboard) search option (never the trailing Add chip) */ .results li button.opt-active, +.results div button.opt-active, .results li[aria-selected="true"] button:not(.result-add) { background: var(--bg-sub); outline: 2px solid var(--accent); diff --git a/apps/postcards/tests/e2e/a11y.spec.ts b/apps/postcards/tests/e2e/a11y.spec.ts index 9e41d3f..e3853c5 100644 --- a/apps/postcards/tests/e2e/a11y.spec.ts +++ b/apps/postcards/tests/e2e/a11y.spec.ts @@ -29,9 +29,17 @@ async function openJournalView(page: Page, name: string): Promise { } // Every top-level screen and every dialog or sheet, each opened from the map. -// The search results list is left out: its rows nest a button in an option. const SCREENS: [string, (page: Page) => Promise][] = [ ["map", async () => {}], + [ + "search results", + async (page) => { + // Paris is visited in the backup and its airports are not, so both chip states show. + await page.getByLabel("Search a city or country").fill("Paris"); + await expect(page.locator("#search-results")).toBeVisible(); + await page.keyboard.press("ArrowDown"); + }, + ], [ "map filter panel", async (page) => { diff --git a/apps/postcards/tests/e2e/flags.spec.ts b/apps/postcards/tests/e2e/flags.spec.ts index ba6098b..9eabba3 100644 --- a/apps/postcards/tests/e2e/flags.spec.ts +++ b/apps/postcards/tests/e2e/flags.spec.ts @@ -39,9 +39,9 @@ test("search results and the map's place card lead with the country's flag", asy await page.goto("/"); await expect(page.getByText("Cities in view")).toBeVisible(); await page.getByLabel("Search a city or country").fill("Istanbul"); - const first = page.locator("#search-results [role=option]").first(); + const first = page.locator("#search-results [role=row]").first(); await expect(first.locator(".result-flag")).toHaveText("๐Ÿ‡น๐Ÿ‡ท"); - // The flag is decoration: the option's name stays the place's name. + // The flag is decoration: the result's name stays the place's name. await expect(first.locator(".result-open")).toHaveAccessibleName(/^Istanbul/); await first.locator(".result-open").click(); diff --git a/apps/postcards/tests/e2e/nav-helper.ts b/apps/postcards/tests/e2e/nav-helper.ts index 9755be7..6f667b8 100644 --- a/apps/postcards/tests/e2e/nav-helper.ts +++ b/apps/postcards/tests/e2e/nav-helper.ts @@ -90,7 +90,7 @@ export async function markVisited(page: Page, query: string, name = query): Prom await page.getByRole("button", { name: `Mark ${name} visited` }).first().click(); await expect(page.getByRole("button", { name: `Remove ${name} from visited` }).first()).toBeVisible(); await search.fill(""); - await expect(page.getByRole("listbox", { name: "Search results" })).toHaveCount(0); + await expect(page.getByRole("grid", { name: "Search results" })).toHaveCount(0); } /** Log a single-leg trip with the Trips screen's quick form, from IATA codes. */ From eda31ef1f7a027cdbad971b34b75cb5d72f9cf23 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:53:20 +0900 Subject: [PATCH 3/3] Trips: let a screen reader read the trip form's place list Axe flagged nested-interactive on the From and To pickers: each option held a button, and an option's children are presentational, so the button inside was hidden from assistive technology. The option now takes the click itself, keeping focus on the field so its arrow keys, Enter and Escape are unchanged, and the axe gate opens the list in light, dark and 375 px. --- .../src/features/travel/PlacePicker.tsx | 25 ++++++++----------- apps/postcards/src/styles.css | 10 +++++--- apps/postcards/tests/e2e/a11y.spec.ts | 10 ++++++++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/postcards/src/features/travel/PlacePicker.tsx b/apps/postcards/src/features/travel/PlacePicker.tsx index c6c2d38..c44a997 100644 --- a/apps/postcards/src/features/travel/PlacePicker.tsx +++ b/apps/postcards/src/features/travel/PlacePicker.tsx @@ -133,26 +133,23 @@ export function PlacePicker({ {open && (
      {results.map((r, i) => ( + // The option itself takes the click: an option may hold no control, + // so a button inside it would be hidden from a screen reader.
    • e.preventDefault()} + onClick={() => choose(r.place)} > - + + {r.place.name} + {r.detail} +
    • ))}
    diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 5664673..2c36d58 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -704,7 +704,8 @@ h1.brand { overflow-y: auto; } .results li button, -.results div button { +.results div button, +.results li[role="option"] { width: 100%; display: flex; align-items: center; @@ -716,16 +717,18 @@ h1.brand { border-radius: var(--radius-sm); text-align: left; color: var(--text); + cursor: pointer; } .results li button:hover, .results li button:focus-visible, .results div button:hover, -.results div button:focus-visible { +.results div button:focus-visible, +.results li[role="option"]:hover { background: var(--bg-sub); } /* The global search splits each row into TWO cells โ€” the first shows the place, the trailing chip logs it โ€” so browsing can never accidentally mark - something visited. (The Trips PlacePicker keeps single-button rows.) */ + something visited. (The Trips PlacePicker keeps one-action rows.) */ .results-split [role="row"] { display: flex; align-items: center; @@ -2432,6 +2435,7 @@ a.btn-ghost { /* Active (keyboard) search option (never the trailing Add chip) */ .results li button.opt-active, .results div button.opt-active, +.results li.opt-active, .results li[aria-selected="true"] button:not(.result-add) { background: var(--bg-sub); outline: 2px solid var(--accent); diff --git a/apps/postcards/tests/e2e/a11y.spec.ts b/apps/postcards/tests/e2e/a11y.spec.ts index e3853c5..9325bff 100644 --- a/apps/postcards/tests/e2e/a11y.spec.ts +++ b/apps/postcards/tests/e2e/a11y.spec.ts @@ -154,6 +154,16 @@ const SCREENS: [string, (page: Page) => Promise][] = [ await expect(page.getByLabel("From", { exact: true })).toBeVisible(); }, ], + [ + "trip form place list", + async (page) => { + await gotoTab(page, "Trips"); + await page.getByRole("button", { name: "New trip" }).click(); + await page.getByLabel("From", { exact: true }).fill("Paris"); + await expect(page.getByRole("listbox", { name: "From" })).toBeVisible(); + await page.keyboard.press("ArrowDown"); + }, + ], [ "trip composer", async (page) => {