diff --git a/assets/css/app.css b/assets/css/app.css index 0adf49b..083bf26 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -148,9 +148,28 @@ html { background-color: var(--color-primary); } -.locale-active { - color: var(--color-neutral-content) !important; - background-color: var(--color-neutral) !important; +.language-switcher-panel { + z-index: 70; + width: min(20rem, calc(100vw - 2rem)); +} + +.language-switcher-options { + overscroll-behavior: contain; +} + +.language-switcher-option.active { + color: var(--color-primary-content); + background-color: var(--color-primary); +} + +.language-switcher-option.active:hover, +.language-switcher-option.active:focus-visible { + color: var(--color-primary-content); + background-color: color-mix(in oklab, var(--color-primary) 88%, black); +} + +[data-language-option][hidden] { + display: none; } .aid-map { diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 08c0415..26a220b 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -942,6 +942,93 @@ export const Hooks = { } }, + LanguageSwitcher: { + mounted() { + this.summary = this.el.querySelector(":scope > summary") + this.search = this.el.querySelector("[data-language-search]") + this.options = [...this.el.querySelectorAll("[data-language-option]")] + this.empty = this.el.querySelector("[data-language-empty]") + this.optionsList = this.el.querySelector(".language-switcher-options") + + this.filter = rawQuery => { + const query = rawQuery.trim().toLocaleLowerCase() + let visibleCount = 0 + + this.options.forEach(option => { + const visible = query === "" || (option.dataset.searchText || "").includes(query) + option.hidden = !visible + if (visible) visibleCount += 1 + }) + + if (this.empty) this.empty.hidden = visibleCount !== 0 + if (this.optionsList) this.optionsList.hidden = visibleCount === 0 + } + + this.close = ({restoreFocus = false} = {}) => { + if (!this.el.open) return + + this.el.open = false + if (restoreFocus) this.summary?.focus() + } + + this.syncExpanded = () => { + const expanded = this.el.open + this.summary?.setAttribute("aria-expanded", String(expanded)) + + if (expanded) { + requestAnimationFrame(() => { + this.search?.focus() + this.search?.select() + }) + } else if (this.search) { + this.search.value = "" + this.filter("") + } + } + + this.onInput = event => { + if (event.target !== this.search) return + this.filter(event.target.value) + } + + this.onKeydown = event => { + if (event.key === "Escape" && this.el.open) { + event.preventDefault() + event.stopPropagation() + this.close({restoreFocus: true}) + return + } + + if (event.target === this.search && event.key === "ArrowDown") { + const firstVisibleLink = this.options + .find(option => !option.hidden) + ?.querySelector("a") + + if (firstVisibleLink) { + event.preventDefault() + firstVisibleLink.focus() + } + } + } + + this.onPointerDown = event => { + if (this.el.open && !this.el.contains(event.target)) this.close() + } + + this.el.addEventListener("toggle", this.syncExpanded) + this.el.addEventListener("input", this.onInput) + this.el.addEventListener("keydown", this.onKeydown) + document.addEventListener("pointerdown", this.onPointerDown) + this.syncExpanded() + }, + destroyed() { + this.el.removeEventListener("toggle", this.syncExpanded) + this.el.removeEventListener("input", this.onInput) + this.el.removeEventListener("keydown", this.onKeydown) + document.removeEventListener("pointerdown", this.onPointerDown) + } + }, + AccessibleDisclosure: { mounted() { this.summary = this.el.querySelector(":scope > summary") diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex index ea0814a..76e46af 100644 --- a/lib/who_need_help/accounts/user.ex +++ b/lib/who_need_help/accounts/user.ex @@ -2,6 +2,8 @@ defmodule WhoNeedHelp.Accounts.User do use Ecto.Schema import Ecto.Changeset + alias WhoNeedHelp.Locales + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "users" do @@ -62,7 +64,7 @@ defmodule WhoNeedHelp.Accounts.User do user |> cast(attrs, [:email, :display_name, :terms_accepted, :locale]) |> validate_required([:display_name, :locale]) - |> validate_inclusion(:locale, ~w(en uk ru)) + |> validate_inclusion(:locale, Locales.codes()) |> validate_acceptance(:terms_accepted, message: "you must confirm that you are 18+ and accept the rules" ) @@ -92,7 +94,7 @@ defmodule WhoNeedHelp.Accounts.User do |> validate_required([:display_name, :locale, :location_visibility, :direct_message_policy]) |> validate_length(:display_name, min: 2, max: 80) |> validate_length(:bio, max: 600) - |> validate_inclusion(:locale, ~w(en uk ru)) + |> validate_inclusion(:locale, Locales.codes()) |> validate_length(:tip_url, max: 255) |> validate_url(:tip_url) end diff --git a/lib/who_need_help/accounts/user_notifier.ex b/lib/who_need_help/accounts/user_notifier.ex index e18d9f5..d8b6592 100644 --- a/lib/who_need_help/accounts/user_notifier.ex +++ b/lib/who_need_help/accounts/user_notifier.ex @@ -92,11 +92,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do end) end - defp with_user_locale(%User{locale: locale}, fun) when locale in ~w(en uk ru) do - Gettext.with_locale(WhoNeedHelpWeb.Gettext, locale, fun) - end - - defp with_user_locale(_user, fun) do - Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun) + defp with_user_locale(%User{locale: locale}, fun) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun) end end diff --git a/lib/who_need_help/locales.ex b/lib/who_need_help/locales.ex new file mode 100644 index 0000000..84be119 --- /dev/null +++ b/lib/who_need_help/locales.ex @@ -0,0 +1,38 @@ +defmodule WhoNeedHelp.Locales do + @moduledoc """ + Central registry for interface locales supported by the application. + + Native language names are intentionally not translated so that people can + recognize their language even when the current interface locale is unknown. + """ + + @default "en" + @locales [ + %{code: "en", short_name: "EN", native_name: "English"}, + %{code: "uk", short_name: "UK", native_name: "Українська"}, + %{code: "ru", short_name: "RU", native_name: "Русский"} + ] + @codes Enum.map(@locales, & &1.code) + + def all, do: @locales + def codes, do: @codes + def default, do: @default + def options, do: Enum.map(@locales, &{&1.native_name, &1.code}) + + def supported?(locale), do: locale in @codes + + def normalize(locale) when is_binary(locale) do + locale + |> String.downcase() + |> String.split(~r/[-_]/, parts: 2) + |> List.first() + |> then(fn candidate -> if supported?(candidate), do: candidate, else: @default end) + end + + def normalize(_locale), do: @default + + def get(locale) do + normalized = normalize(locale) + Enum.find(@locales, &(&1.code == normalized)) + end +end diff --git a/lib/who_need_help/notifications/email_notifier.ex b/lib/who_need_help/notifications/email_notifier.ex index bbe8847..d5e9ee8 100644 --- a/lib/who_need_help/notifications/email_notifier.ex +++ b/lib/who_need_help/notifications/email_notifier.ex @@ -30,11 +30,7 @@ defmodule WhoNeedHelp.Notifications.EmailNotifier do end) end - defp with_user_locale(%User{locale: locale}, fun) when locale in ~w(en uk ru) do - Gettext.with_locale(WhoNeedHelpWeb.Gettext, locale, fun) - end - - defp with_user_locale(_user, fun) do - Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun) + defp with_user_locale(%User{locale: locale}, fun) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun) end end diff --git a/lib/who_need_help/notifications/text.ex b/lib/who_need_help/notifications/text.ex index 0964579..40f66f2 100644 --- a/lib/who_need_help/notifications/text.ex +++ b/lib/who_need_help/notifications/text.ex @@ -10,10 +10,9 @@ defmodule WhoNeedHelp.Notifications.Text do use Gettext, backend: WhoNeedHelpWeb.Gettext + alias WhoNeedHelp.Locales alias WhoNeedHelp.Notifications.Notification - @supported_locales ~w(en uk ru) - def render(%Notification{} = notification, locale) do with_locale(locale, fn -> notification @@ -148,6 +147,5 @@ defmodule WhoNeedHelp.Notifications.Text do Gettext.with_locale(WhoNeedHelpWeb.Gettext, normalized_locale(locale), fun) end - defp normalized_locale(locale) when locale in @supported_locales, do: locale - defp normalized_locale(_locale), do: "en" + defp normalized_locale(locale), do: Locales.normalize(locale) end diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex index 5dee141..2f142eb 100644 --- a/lib/who_need_help_web/components/layouts.ex +++ b/lib/who_need_help_web/components/layouts.ex @@ -72,7 +72,7 @@ defmodule WhoNeedHelpWeb.Layouts do class="ml-1 flex items-center gap-1 border-l border-base-300 pl-2" aria-label={gettext("Account and language")} > - <.locale_switcher /> + <.locale_switcher id="desktop-language-switcher" /> <.theme_toggle /> <%= if @current_scope do %> @@ -111,7 +111,7 @@ defmodule WhoNeedHelpWeb.Layouts do <.icon name="hero-bars-3" class="size-6" /> -