diff --git a/assets/css/app.css b/assets/css/app.css index 1d6c9fa..e2d3c98 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -65,7 +65,7 @@ --color-base-100: oklch(98% 0 0); --color-base-200: oklch(96% 0.001 286.375); --color-base-300: oklch(92% 0.004 286.32); - --color-base-content: oklch(21% 0.006 285.885); + --color-base-content: oklch(18% 0.006 285.885); --color-primary: #9a3412; --color-primary-content: #ffffff; --color-secondary: oklch(55% 0.027 264.364); @@ -74,13 +74,13 @@ --color-accent-content: oklch(100% 0 0); --color-neutral: oklch(44% 0.017 285.786); --color-neutral-content: oklch(98% 0 0); - --color-info: oklch(62% 0.214 259.815); + --color-info: #1d4ed8; --color-info-content: oklch(97% 0.014 254.604); --color-success: oklch(45% 0.12 182.503); --color-success-content: oklch(98% 0.014 180.72); --color-warning: #b45309; --color-warning-content: #fffaf0; - --color-error: oklch(58% 0.253 17.585); + --color-error: #be123c; --color-error-content: oklch(96% 0.015 12.422); --radius-selector: 0.25rem; --radius-field: 0.25rem; @@ -109,6 +109,12 @@ html { scrollbar-gutter: stable; } +/* Keep compact field labels readable on both the light and dark secondary + surfaces. The component-library default falls just below WCAG AA at 12px. */ +.label { + color: var(--color-base-content); +} + .skip-link { position: fixed; z-index: 100; @@ -328,6 +334,17 @@ html { } @media (max-width: 767px) { + .aid-map .maplibregl-ctrl-group button, + .aid-map .maplibregl-ctrl-attrib-button { + width: 2.75rem; + height: 2.75rem; + } + + .request-map-cluster { + min-width: 2.75rem; + min-height: 2.75rem; + } + .request-discovery-toolbar { align-items: stretch; flex-direction: column; @@ -715,7 +732,7 @@ html { } .request-location-mode small { - color: color-mix(in oklab, var(--color-base-content) 58%, transparent); + color: color-mix(in oklab, var(--color-base-content) 72%, transparent); font-size: 0.68rem; line-height: 1.25; } @@ -817,7 +834,7 @@ html { .request-location-radius span { display: grid; - min-height: 2.5rem; + min-height: 2.75rem; place-items: center; padding: 0.5rem; border: 1px solid var(--color-base-300); diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 1da44a0..08f9c0c 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -2,6 +2,80 @@ import maplibregl from "maplibre-gl" const trackingMinTimeMs = 5000 +const uiLocale = () => document.documentElement.lang || undefined + +const mapLocaleByLanguage = { + ru: { + "AttributionControl.ToggleAttribution": "Показать сведения об источниках", + "AttributionControl.MapFeedback": "Сообщить о карте", + "FullscreenControl.Enter": "На весь экран", + "FullscreenControl.Exit": "Выйти из полноэкранного режима", + "GeolocateControl.FindMyLocation": "Найти моё местоположение", + "GeolocateControl.LocationNotAvailable": "Местоположение недоступно", + "LogoControl.Title": "Логотип MapLibre", + "Map.Title": "Карта", + "Marker.Title": "Маркер на карте", + "NavigationControl.ResetBearing": + "Перетащите, чтобы повернуть карту; нажмите, чтобы вернуть север вверх", + "NavigationControl.ZoomIn": "Увеличить масштаб", + "NavigationControl.ZoomOut": "Уменьшить масштаб", + "Popup.Close": "Закрыть", + "ScaleControl.Feet": "футы", + "ScaleControl.Meters": "метры", + "ScaleControl.Kilometers": "километры", + "ScaleControl.Miles": "мили", + "ScaleControl.NauticalMiles": "морские мили", + "GlobeControl.Enable": "Показать глобус", + "GlobeControl.Disable": "Показать плоскую карту", + "TerrainControl.Enable": "Показать рельеф", + "TerrainControl.Disable": "Скрыть рельеф", + "CooperativeGesturesHandler.WindowsHelpText": + "Удерживайте Ctrl при прокрутке, чтобы изменить масштаб карты", + "CooperativeGesturesHandler.MacHelpText": + "Удерживайте ⌘ при прокрутке, чтобы изменить масштаб карты", + "CooperativeGesturesHandler.MobileHelpText": + "Используйте два пальца, чтобы перемещать карту" + }, + uk: { + "AttributionControl.ToggleAttribution": "Показати відомості про джерела", + "AttributionControl.MapFeedback": "Повідомити про карту", + "FullscreenControl.Enter": "На весь екран", + "FullscreenControl.Exit": "Вийти з повноекранного режиму", + "GeolocateControl.FindMyLocation": "Знайти моє місцезнаходження", + "GeolocateControl.LocationNotAvailable": "Місцезнаходження недоступне", + "LogoControl.Title": "Логотип MapLibre", + "Map.Title": "Карта", + "Marker.Title": "Маркер на карті", + "NavigationControl.ResetBearing": + "Перетягніть, щоб повернути карту; натисніть, щоб повернути північ угору", + "NavigationControl.ZoomIn": "Збільшити масштаб", + "NavigationControl.ZoomOut": "Зменшити масштаб", + "Popup.Close": "Закрити", + "ScaleControl.Feet": "фути", + "ScaleControl.Meters": "метри", + "ScaleControl.Kilometers": "кілометри", + "ScaleControl.Miles": "милі", + "ScaleControl.NauticalMiles": "морські милі", + "GlobeControl.Enable": "Показати глобус", + "GlobeControl.Disable": "Показати пласку карту", + "TerrainControl.Enable": "Показати рельєф", + "TerrainControl.Disable": "Сховати рельєф", + "CooperativeGesturesHandler.WindowsHelpText": + "Утримуйте Ctrl під час прокручування, щоб змінити масштаб карти", + "CooperativeGesturesHandler.MacHelpText": + "Утримуйте ⌘ під час прокручування, щоб змінити масштаб карти", + "CooperativeGesturesHandler.MobileHelpText": + "Використовуйте два пальці, щоб переміщувати карту" + } +} + +const mapUiLocale = () => { + const language = (uiLocale() || "en").toLowerCase().split("-")[0] + return mapLocaleByLanguage[language] || {} +} + +const mapRegionLabel = () => mapUiLocale()["Map.Title"] || "Map" + const defaultStyle = { version: 8, sources: { @@ -270,6 +344,8 @@ const createAidMap = element => { const fallback = document.createElement("div") fallback.className = "map-unavailable-panel" fallback.dataset.mapFallback = "true" + fallback.setAttribute("role", "region") + fallback.setAttribute("aria-label", mapRegionLabel()) const message = document.createElement("p") message.textContent = state.element.dataset.mapUnavailableLabel @@ -281,6 +357,8 @@ const createAidMap = element => { const fallback = document.createElement("div") fallback.className = "home-demo-map-fallback" fallback.dataset.mapFallback = "true" + fallback.setAttribute("role", "region") + fallback.setAttribute("aria-label", mapRegionLabel()) const note = document.createElement("p") note.className = "home-demo-map-fallback-note" @@ -348,7 +426,7 @@ const createAidMap = element => { const capturedAt = point.captured_at ? new Date(point.captured_at) : null const capturedAtLabel = capturedAt && !Number.isNaN(capturedAt.getTime()) - ? new Intl.DateTimeFormat(undefined, { + ? new Intl.DateTimeFormat(uiLocale(), { dateStyle: "short", timeStyle: "medium" }).format(capturedAt) @@ -568,6 +646,7 @@ const createAidMap = element => { style: defaultStyle, center: [30.5234, 50.4501], zoom: 5, + locale: mapUiLocale(), attributionControl: true, renderWorldCopies: false, trackResize: false, @@ -1171,7 +1250,7 @@ export const Hooks = { if (dateStyle !== "none") options.dateStyle = dateStyle if (timeStyle !== "none") options.timeStyle = timeStyle - this.el.textContent = new Intl.DateTimeFormat(undefined, options).format(date) + this.el.textContent = new Intl.DateTimeFormat(uiLocale(), options).format(date) this.el.title = date.toISOString() } @@ -1481,6 +1560,8 @@ export const Hooks = { const fallback = document.createElement("div") fallback.className = "map-unavailable-panel" fallback.dataset.mapFallback = "true" + fallback.setAttribute("role", "region") + fallback.setAttribute("aria-label", mapRegionLabel()) const message = document.createElement("p") message.textContent = this.mapElement.dataset.mapUnavailableLabel @@ -1794,6 +1875,7 @@ export const Hooks = { ? [initialCoordinates.longitude, initialCoordinates.latitude] : [30.5234, 50.4501], zoom: initialCoordinates ? 13 : 5, + locale: mapUiLocale(), attributionControl: true, dragRotate: false, pitchWithRotate: false diff --git a/e2e/tests/accessibility-resilience.spec.ts b/e2e/tests/accessibility-resilience.spec.ts index 0a94696..98c068c 100644 --- a/e2e/tests/accessibility-resilience.spec.ts +++ b/e2e/tests/accessibility-resilience.spec.ts @@ -1,6 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -import { newIsolatedContext } from "./helpers"; +import { newIsolatedContext, projectEmail, registerAndConfirm } from "./helpers"; const publicAccessibilityPaths = [ "/", @@ -111,3 +111,42 @@ test("public navigation does not overflow narrow or wide viewports", async ({ br await context.close(); } }); + +test("authenticated core workflows remain accessible on a phone viewport", async ({ + browser, + request, +}, testInfo) => { + const user = await registerAndConfirm( + browser, + request, + projectEmail("mobile-accessibility", testInfo.project.name), + "Mobile accessibility", + ); + + await user.page.setViewportSize({ width: 390, height: 844 }); + + for (const path of [ + "/requests", + "/requests/new", + "/activities", + "/notifications", + "/profile", + "/users/settings", + "/support/requests", + "/reports", + ]) { + await user.page.goto(path); + await expectAccessible(user.page); + + const dimensions = await user.page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + + expect(dimensions.scrollWidth, `${path} overflowed at 390px`).toBeLessThanOrEqual( + dimensions.clientWidth, + ); + } + + await user.context.close(); +}); diff --git a/e2e/tests/localization.spec.ts b/e2e/tests/localization.spec.ts index da071a8..2cd50dd 100644 --- a/e2e/tests/localization.spec.ts +++ b/e2e/tests/localization.spec.ts @@ -4,6 +4,7 @@ import { newIsolatedContext, projectEmail, registerAndConfirm, + waitForMapReady, } from "./helpers"; test("public language selection persists across pages", async ({ browser }) => { @@ -46,5 +47,25 @@ test("authenticated LiveView uses the selected browser language", async ({ await expect(user.page.getByRole("heading", { name: "Хто потребує допомоги?" })).toBeVisible(); await expect(user.page.getByRole("link", { name: "Створити термінову заявку" })).toBeVisible(); + await gotoLiveView(user.page, "/notifications"); + await expect(user.page.getByRole("heading", { name: "Сповіщення" })).toBeVisible(); + await expect(user.page.getByText("0 непрочитаних сповіщень")).toBeVisible(); + await expect( + user.page.getByText( + "Тут з’являться оновлення заявок, повідомлень і сповіщень поблизу.", + ), + ).toBeVisible(); + + await gotoLiveView(user.page, "/activities"); + await waitForMapReady(user.page); + await expect(user.page.getByRole("region", { name: "Карта" })).toBeVisible(); + + if ((await user.page.locator("#activity-map canvas").count()) > 0) { + await expect(user.page.getByRole("button", { name: "Збільшити масштаб" })).toBeVisible(); + await expect( + user.page.getByRole("button", { name: "Знайти моє місцезнаходження" }), + ).toBeVisible(); + } + await user.context.close(); }); diff --git a/lib/who_need_help_web/live/profile_live.ex b/lib/who_need_help_web/live/profile_live.ex index 2527fcb..165f2b9 100644 --- a/lib/who_need_help_web/live/profile_live.ex +++ b/lib/who_need_help_web/live/profile_live.ex @@ -183,7 +183,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do