diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json index cc08ce9..26e4f8d 100644 --- a/public/_locales/de/messages.json +++ b/public/_locales/de/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Links" + }, + "enabledCountLabel": { + "message": "$1 von $2 Trackern aktiviert", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "Alle Tracker sind bereits aktiviert" + }, + "allTrackersAlreadyDisabled": { + "message": "Alle Tracker sind bereits deaktiviert" + }, + "preferencesSaveError": { + "message": "Änderungen konnten nicht gespeichert werden. Versuchen Sie es erneut." + }, + "externalLinkLabel": { + "message": "$1 (wird in neuem Tab geöffnet)", + "placeholders": { + "label": { + "content": "$1", + "example": "Open Source" + } + } } } diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 801e8d2..85c1546 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Links" + }, + "enabledCountLabel": { + "message": "$1 of $2 trackers enabled", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "All trackers are already enabled" + }, + "allTrackersAlreadyDisabled": { + "message": "All trackers are already disabled" + }, + "preferencesSaveError": { + "message": "Couldn't save changes. Try again." + }, + "externalLinkLabel": { + "message": "$1 (opens in new tab)", + "placeholders": { + "label": { + "content": "$1", + "example": "Open source" + } + } } } diff --git a/public/_locales/es/messages.json b/public/_locales/es/messages.json index 1ce3230..1d17d45 100644 --- a/public/_locales/es/messages.json +++ b/public/_locales/es/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Enlaces" + }, + "enabledCountLabel": { + "message": "$1 de $2 rastreadores activados", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "Todos los rastreadores ya están activados" + }, + "allTrackersAlreadyDisabled": { + "message": "Todos los rastreadores ya están desactivados" + }, + "preferencesSaveError": { + "message": "No se pudieron guardar los cambios. Inténtalo de nuevo." + }, + "externalLinkLabel": { + "message": "$1 (se abre en una pestaña nueva)", + "placeholders": { + "label": { + "content": "$1", + "example": "Código abierto" + } + } } } diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json index a8b0419..6d952d0 100644 --- a/public/_locales/fr/messages.json +++ b/public/_locales/fr/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Liens" + }, + "enabledCountLabel": { + "message": "$1 tracker(s) activé(s) sur $2", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "Tous les trackers sont déjà activés" + }, + "allTrackersAlreadyDisabled": { + "message": "Tous les trackers sont déjà désactivés" + }, + "preferencesSaveError": { + "message": "Impossible d'enregistrer les modifications. Réessayez." + }, + "externalLinkLabel": { + "message": "$1 (s'ouvre dans un nouvel onglet)", + "placeholders": { + "label": { + "content": "$1", + "example": "Open source" + } + } } } diff --git a/public/_locales/pt_BR/messages.json b/public/_locales/pt_BR/messages.json index 52ae103..ee1ffa9 100644 --- a/public/_locales/pt_BR/messages.json +++ b/public/_locales/pt_BR/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Links" + }, + "enabledCountLabel": { + "message": "$1 de $2 rastreadores ativados", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "Todos os rastreadores já estão ativados" + }, + "allTrackersAlreadyDisabled": { + "message": "Todos os rastreadores já estão desativados" + }, + "preferencesSaveError": { + "message": "Não foi possível salvar as alterações. Tente novamente." + }, + "externalLinkLabel": { + "message": "$1 (abre em uma nova aba)", + "placeholders": { + "label": { + "content": "$1", + "example": "Código aberto" + } + } } } diff --git a/public/_locales/ru/messages.json b/public/_locales/ru/messages.json index 7a1ef76..851e4dc 100644 --- a/public/_locales/ru/messages.json +++ b/public/_locales/ru/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "Ссылки" + }, + "enabledCountLabel": { + "message": "Включено трекеров: $1 из $2", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "Все трекеры уже включены" + }, + "allTrackersAlreadyDisabled": { + "message": "Все трекеры уже выключены" + }, + "preferencesSaveError": { + "message": "Не удалось сохранить изменения. Повторите попытку." + }, + "externalLinkLabel": { + "message": "$1 (откроется в новой вкладке)", + "placeholders": { + "label": { + "content": "$1", + "example": "Открытый код" + } + } } } diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 5b6504c..379ab5c 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -67,5 +67,36 @@ }, "settingsLinks": { "message": "链接" + }, + "enabledCountLabel": { + "message": "已启用 $1 / $2 个追踪器", + "placeholders": { + "enabled": { + "content": "$1", + "example": "3" + }, + "total": { + "content": "$2", + "example": "7" + } + } + }, + "allTrackersAlreadyEnabled": { + "message": "所有追踪器都已启用" + }, + "allTrackersAlreadyDisabled": { + "message": "所有追踪器都已禁用" + }, + "preferencesSaveError": { + "message": "无法保存更改。请重试。" + }, + "externalLinkLabel": { + "message": "$1(在新标签页中打开)", + "placeholders": { + "label": { + "content": "$1", + "example": "开源" + } + } } } diff --git a/src/entrypoints/steam.content/style.css b/src/entrypoints/steam.content/style.css index be7b56b..a7254d1 100644 --- a/src/entrypoints/steam.content/style.css +++ b/src/entrypoints/steam.content/style.css @@ -15,10 +15,25 @@ } .trackeroo-empty .count_link_label { + display: block; font-size: 12px; color: #56707f; } +.trackeroo-empty-help { + display: block; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font: + 11px / 14px "Motiva Sans", + Arial, + Helvetica, + sans-serif; + color: #8f98a0; + white-space: nowrap; +} + .trackeroo-trigger:hover .count_link_label, .trackeroo-root.is-open .count_link_label { color: #fff; @@ -75,6 +90,7 @@ cursor: default; } +.trackeroo-trigger:focus-visible, .trackeroo-menu-item:focus-visible { outline: 1px solid #66c0f4; outline-offset: -1px; diff --git a/src/popup/app.tsx b/src/popup/app.tsx index 8fa31e3..d2a4d3b 100644 --- a/src/popup/app.tsx +++ b/src/popup/app.tsx @@ -7,13 +7,41 @@ import type { PopupTab } from "./components/popup-tabs"; import { SettingsTab } from "./components/settings-tab"; import { TrackersTab } from "./components/trackers-tab"; +const skeletonRows = ["first", "second", "third", "fourth"] as const; + +function PopupLoadingSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ {skeletonRows.map((row) => ( +
+ ))} +
+
+ ); +} + export function PopupApp() { const { ready, locale, setLocale } = useLocale(); - const { preferences, toggle, setAll } = useTrackerPreferences(); + const { error, preferences, toggle, setAll } = useTrackerPreferences(); const [activeTab, setActiveTab] = useState("trackers"); if (!ready) { - return null; + return ; } const enabledCount = TRACKERS.filter( @@ -29,6 +57,7 @@ export function PopupApp() { > {activeTab === "trackers" ? (
-

{t("extName")}

- - {enabledCount}/{totalCount} +
+

+ {t("extName")} +

+

+ {t("extDescription")} +

+
+ + {enabledCountLabel} +
-
+
{children}
diff --git a/src/popup/components/popup-tabs.tsx b/src/popup/components/popup-tabs.tsx index 7f778a3..1976140 100644 --- a/src/popup/components/popup-tabs.tsx +++ b/src/popup/components/popup-tabs.tsx @@ -1,3 +1,4 @@ +import type { KeyboardEvent } from "react"; import { t } from "@/i18n/runtime"; export type PopupTab = "trackers" | "settings"; @@ -9,26 +10,78 @@ interface PopupTabsProps { const tabs: PopupTab[] = ["trackers", "settings"]; +export function getPopupTabId(tab: PopupTab): string { + return `popup-tab-${tab}`; +} + +export function getPopupTabPanelId(tab: PopupTab): string { + return `popup-tab-panel-${tab}`; +} + function tabLabel(tab: PopupTab): string { return tab === "trackers" ? t("tabTrackers") : t("tabSettings"); } export function PopupTabs({ active, onChange }: PopupTabsProps) { + const selectTab = (tab: PopupTab) => { + onChange(tab); + requestAnimationFrame(() => + document.getElementById(getPopupTabId(tab))?.focus() + ); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const activeIndex = tabs.indexOf(active); + const selectByIndex = (index: number) => { + selectTab(tabs[(index + tabs.length) % tabs.length] ?? tabs[0]); + }; + + switch (event.key) { + case "ArrowLeft": + case "ArrowUp": + event.preventDefault(); + selectByIndex(activeIndex - 1); + break; + case "ArrowRight": + case "ArrowDown": + event.preventDefault(); + selectByIndex(activeIndex + 1); + break; + case "Home": + event.preventDefault(); + selectTab(tabs[0]); + break; + case "End": + event.preventDefault(); + selectTab(tabs.at(-1) ?? tabs[0]); + break; + default: + break; + } + }; + return ( -
+
{tabs.map((tab) => { const selected = active === tab; return ( ); } diff --git a/src/popup/components/trackers-tab.tsx b/src/popup/components/trackers-tab.tsx index b16faf7..6771a6b 100644 --- a/src/popup/components/trackers-tab.tsx +++ b/src/popup/components/trackers-tab.tsx @@ -4,15 +4,17 @@ import type { TrackerId, TrackerPreferences } from "@/trackers/types"; import { Toggle } from "./toggle"; interface TrackersTabProps { + error: boolean; onSetAll: (enabled: boolean) => void; onToggle: (id: TrackerId) => void; preferences: TrackerPreferences; } const actionClass = - "text-[12px] text-neutral-500 transition-colors hover:text-white disabled:cursor-default disabled:text-neutral-700"; + "rounded-sm text-[12px] text-neutral-500 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black disabled:cursor-default disabled:text-neutral-700"; export function TrackersTab({ + error, preferences, onToggle, onSetAll, @@ -27,6 +29,7 @@ export function TrackersTab({ className={actionClass} disabled={allOn} onClick={() => onSetAll(true)} + title={allOn ? t("allTrackersAlreadyEnabled") : undefined} type="button" > {t("turnAllOn")} @@ -38,31 +41,38 @@ export function TrackersTab({ className={actionClass} disabled={allOff} onClick={() => onSetAll(false)} + title={allOff ? t("allTrackersAlreadyDisabled") : undefined} type="button" > {t("turnAllOff")}
+ {error ? ( +

+ {t("preferencesSaveError")} +

+ ) : null} +
    - {TRACKERS.map((tracker) => ( -
  • -
    - - {getTrackerHost(tracker)} - + {TRACKERS.map((tracker) => { + const host = getTrackerHost(tracker); + + return ( +
  • onToggle(tracker.id)} /> -
- - ))} + + ); + })}
); diff --git a/src/tracker-menu/elements.test.ts b/src/tracker-menu/elements.test.ts index f2cf89a..d1913e7 100644 --- a/src/tracker-menu/elements.test.ts +++ b/src/tracker-menu/elements.test.ts @@ -60,11 +60,17 @@ describe("createDropdownTrigger", () => { const trigger = createDropdownTrigger(2); expect(trigger.getAttribute("role")).toBe("button"); + expect(trigger.getAttribute("aria-label")).toBe("trackersWithCount:2"); expect(trigger.getAttribute("aria-haspopup")).toBe("true"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.querySelector(".count_link_label")?.textContent).toBe( "trackersWithCount:2" ); + expect( + trigger + .querySelector(".profile_count_link_total") + ?.getAttribute("aria-hidden") + ).toBe("true"); }); it("omits the count when no trackers are enabled", () => { @@ -98,5 +104,8 @@ describe("createEmptyState", () => { expect(empty.querySelector(".count_link_label")?.textContent).toBe( "noTrackersEnabled" ); + expect(empty.querySelector(".trackeroo-empty-help")?.textContent).toBe( + "emptyStateTitle" + ); }); }); diff --git a/src/tracker-menu/elements.ts b/src/tracker-menu/elements.ts index d952f64..bb7a7bc 100644 --- a/src/tracker-menu/elements.ts +++ b/src/tracker-menu/elements.ts @@ -46,6 +46,9 @@ export function createDropdownMenu( export function createDropdownTrigger(count: number): HTMLDivElement { const trigger = document.createElement("div"); trigger.className = "profile_count_link ellipsis trackeroo-trigger"; + const label = + count > 0 ? t("trackersWithCount", String(count)) : t("trackers"); + trigger.setAttribute("aria-label", label); trigger.setAttribute("role", "button"); trigger.setAttribute("tabindex", "0"); trigger.setAttribute("aria-expanded", "false"); @@ -56,10 +59,9 @@ export function createDropdownTrigger(count: number): HTMLDivElement { link.className = "trackeroo-trigger-link"; link.tabIndex = -1; - const label = - count > 0 ? t("trackersWithCount", String(count)) : t("trackers"); const caret = document.createElement("span"); caret.className = "profile_count_link_total"; + caret.setAttribute("aria-hidden", "true"); caret.textContent = "▾"; link.append(createCountLabel(label), "\u00a0", caret); @@ -87,6 +89,9 @@ export function createEmptyState(): HTMLDivElement { const el = document.createElement("div"); el.className = "profile_count_link ellipsis trackeroo-empty"; el.title = t("emptyStateTitle"); - el.append(createCountLabel(t("noTrackersEnabled"))); + const help = document.createElement("span"); + help.className = "trackeroo-empty-help"; + help.textContent = t("emptyStateTitle"); + el.append(createCountLabel(t("noTrackersEnabled")), help); return el; } diff --git a/src/tracker-menu/mount.test.ts b/src/tracker-menu/mount.test.ts index da73ca7..cab3179 100644 --- a/src/tracker-menu/mount.test.ts +++ b/src/tracker-menu/mount.test.ts @@ -33,10 +33,13 @@ const ctx = { }, } as unknown as ContentScriptContext; -function mount(enabledTrackers: Tracker[]): HTMLElement { +function mount( + enabledTrackers: Tracker[], + pageUrl: string = PROFILE_URL +): HTMLElement { const container = document.createElement("div"); document.body.append(container); - mountTrackerUi(ctx, container, enabledTrackers, PROFILE_URL); + mountTrackerUi(ctx, container, enabledTrackers, pageUrl); return container; } @@ -62,6 +65,13 @@ describe("mountTrackerUi", () => { expect(link?.href).toBe("https://xsteamcommunity.com/id/gaben"); }); + it("shows the empty state when a single enabled tracker cannot link", () => { + const container = mount([trackers[0]], "https://example.com"); + + expect(container.className).toContain("trackeroo-root--empty"); + expect(container.querySelector(".trackeroo-empty")).not.toBeNull(); + }); + it("renders a dropdown for multiple enabled trackers", () => { const container = mount(trackers); diff --git a/src/tracker-menu/mount.ts b/src/tracker-menu/mount.ts index ba09f71..42e59d6 100644 --- a/src/tracker-menu/mount.ts +++ b/src/tracker-menu/mount.ts @@ -20,6 +20,8 @@ export function mountTrackerUi( if (enabledTrackers.length === 1) { const link = createDirectLink(enabledTrackers[0], pageUrl); if (!link) { + container.className = "trackeroo-root trackeroo-root--empty"; + container.append(createEmptyState()); return; } diff --git a/src/trackers/use-tracker-preferences.ts b/src/trackers/use-tracker-preferences.ts index f49c33e..595e726 100644 --- a/src/trackers/use-tracker-preferences.ts +++ b/src/trackers/use-tracker-preferences.ts @@ -11,6 +11,7 @@ export function useTrackerPreferences() { const [preferences, setPreferences] = useState( getDefaultPreferences ); + const [error, setError] = useState(false); useEffect(() => { getTrackerPreferences().then(setPreferences); @@ -18,14 +19,17 @@ export function useTrackerPreferences() { const toggle = (id: TrackerId) => { const enabled = !preferences[id]; + setError(false); setPreferences((current) => ({ ...current, [id]: enabled })); setTrackerPreference(id, enabled).catch(() => { setPreferences((current) => ({ ...current, [id]: !enabled })); + setError(true); }); }; const setAll = (enabled: boolean) => { const previous = preferences; + setError(false); setPreferences( Object.fromEntries( Object.keys(preferences).map((id) => [id, enabled]) @@ -33,8 +37,9 @@ export function useTrackerPreferences() { ); setAllTrackerPreferences(enabled).catch(() => { setPreferences(previous); + setError(true); }); }; - return { preferences, toggle, setAll }; + return { error, preferences, toggle, setAll }; }