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({ + + {r.place.name} + {r.detail} + ))} diff --git a/apps/postcards/src/features/visits/AddPlaceForm.tsx b/apps/postcards/src/features/visits/AddPlaceForm.tsx index 4762ae6..b5d952a 100644 --- a/apps/postcards/src/features/visits/AddPlaceForm.tsx +++ b/apps/postcards/src/features/visits/AddPlaceForm.tsx @@ -147,6 +147,7 @@ export function AddPlaceForm({ value={name} maxLength={200} placeholder={t("addPlace.namePlaceholder")} + title={t("addPlace.namePlaceholder")} aria-label={t("addPlace.namePlaceholder")} autoFocus onChange={(e) => setName(e.target.value)} @@ -154,6 +155,7 @@ export function AddPlaceForm({ setCoords(e.target.value)} /> @@ -230,6 +240,7 @@ export function AddPlaceForm({ min={0} value={population} placeholder={t("addPlace.populationPlaceholder")} + title={t("addPlace.populationAria")} aria-label={t("addPlace.populationAria")} onChange={(e) => setPopulation(e.target.value)} /> diff --git a/apps/postcards/src/features/visits/PhotoGallery.tsx b/apps/postcards/src/features/visits/PhotoGallery.tsx index 6f56a98..1e5a256 100644 --- a/apps/postcards/src/features/visits/PhotoGallery.tsx +++ b/apps/postcards/src/features/visits/PhotoGallery.tsx @@ -154,6 +154,7 @@ export function PhotoGallery({ type="button" className="postcard-thumb" onClick={() => setOpen(true)} + title={t.plural("photo.viewAria", count, { place: placeName })} aria-label={t.plural("photo.viewAria", count, { place: placeName })} > {/* Row thumbnails decode the full downscaled data URL โ€” lazy + async @@ -168,6 +169,7 @@ export function PhotoGallery({ className="mini-btn" disabled={busy} onClick={() => inputRef.current?.click()} + title={t("photo.addAria", { place: placeName })} aria-label={t("photo.addAria", { place: placeName })} > {busy ? "โ€ฆ" : <>๐Ÿ“ท {t("photo.photos")}} @@ -214,6 +216,7 @@ export function PhotoGallery({ -
diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 440295a..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 @@ -171,8 +174,10 @@ 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-haspopup="grid" aria-expanded={results.length > 0} aria-controls="search-results" aria-autocomplete="list" @@ -207,62 +212,61 @@ export function PlaceSearch({ : ""}

{results.length > 0 && ( -
    {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 ( -
  • - - {/* 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/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/src/styles.css b/apps/postcards/src/styles.css index 85e8ba7..2c36d58 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -703,7 +703,9 @@ h1.brand { max-height: 320px; overflow-y: auto; } -.results li button { +.results li button, +.results div button, +.results li[role="option"] { width: 100%; display: flex; align-items: center; @@ -715,24 +717,29 @@ 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 li button:focus-visible, +.results div button:hover, +.results div button:focus-visible, +.results li[role="option"]:hover { 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 { + something visited. (The Trips PlacePicker keeps one-action rows.) */ +.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 +2434,8 @@ 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.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..9325bff 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) => { @@ -146,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) => { 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. */ 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([]); }); });