Complete localization and mobile accessibility

This commit is contained in:
SimpleTest 2026-07-25 16:22:18 +03:00
parent 13fbeec958
commit 7581283b47
10 changed files with 822 additions and 617 deletions

View File

@ -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);

View File

@ -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

View File

@ -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();
});

View File

@ -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();
});

View File

@ -183,7 +183,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
<aside class="space-y-5">
<div class="rounded-3xl bg-success p-6 text-success-content">
<h2 class="font-bold">{gettext("Public reputation")}</h2>
<p class="mt-1 text-xs opacity-75">
<p class="mt-1 text-xs">
{gettext(
"These are activity signals, not an identity, background, qualification, or safety check."
)}

View File

@ -122,7 +122,7 @@ defmodule WhoNeedHelpWeb.PublicProfileLive do
<aside class="space-y-6">
<section class="rounded-3xl bg-success p-6 text-success-content">
<h2 class="font-bold">{gettext("Public reputation")}</h2>
<p class="mt-1 text-xs opacity-75">
<p class="mt-1 text-xs">
{gettext(
"These are activity signals, not an identity, background, qualification, or safety check."
)}

View File

@ -558,7 +558,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{gettext("Load more in this area")}
</button>
<p class="flex items-start gap-2 rounded-2xl bg-base-200 p-4 text-xs text-base-content/60">
<p class="flex items-start gap-2 rounded-2xl bg-base-200 p-4 text-xs text-base-content/70">
<.icon name="hero-eye-slash" class="mt-0.5 size-4 shrink-0" />
<span>
{gettext(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
defmodule WhoNeedHelpWeb.GettextCatalogTest do
use ExUnit.Case, async: true
@locales ~w(ru uk)
@domains ~w(default errors)
test "supported translated catalogs contain no fuzzy or empty messages" do
for locale <- @locales, domain <- @domains do
path = "priv/gettext/#{locale}/LC_MESSAGES/#{domain}.po"
catalog = Expo.PO.parse_file!(path)
for message <- catalog.messages, not message.obsolete do
refute Expo.Message.has_flag?(message, "fuzzy"),
"#{path} has a fuzzy translation for #{inspect(message.msgid)}"
source_variables = variables(message.msgid)
message
|> translations()
|> Enum.each(fn translation ->
refute empty?(translation),
"#{path} has an empty translation for #{inspect(message.msgid)}"
assert variables(translation) == source_variables,
"#{path} changes interpolation variables for #{inspect(message.msgid)}"
end)
end
end
end
defp translations(%{msgstr: translations}) when is_map(translations) do
Map.values(translations)
end
defp translations(%{msgstr: translation}), do: [translation]
defp empty?(parts), do: parts |> IO.iodata_to_binary() |> String.trim() == ""
defp variables(parts) do
parts
|> IO.iodata_to_binary()
|> then(&Regex.scan(~r/%{[a-zA-Z0-9_]+}/, &1))
|> List.flatten()
|> MapSet.new()
end
end