Complete localization and mobile accessibility
This commit is contained in:
parent
13fbeec958
commit
7581283b47
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
46
test/who_need_help_web/gettext_catalog_test.exs
Normal file
46
test/who_need_help_web/gettext_catalog_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user