feat: show one row of themes with a show all button

This commit is contained in:
Linus Rath
2026-10-19 20:49:24 +02:00
parent 9ef7e686ce
commit 31f4a4b4b3
31 changed files with 197 additions and 45 deletions
@@ -0,0 +1,53 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { ThemesSettings } from '../themes-settings';
import { useThemeStore } from '@/stores/theme-store';
// The gallery shows one row of cards until "Show all", keeps the active theme
// in that row, and opens when a search result points at a hidden theme.
// vitest.setup.ts renders labels as their raw keys; the built-in themes are
// the store's own.
function cardNames(): string[] {
// Cards only: the embedded wrapper carries the section's own search label.
return Array.from(document.querySelectorAll('[data-search-label].relative'))
.map((el) => el.getAttribute('data-search-label') ?? '');
}
describe('ThemesSettings collapse', () => {
beforeEach(() => {
act(() => { useThemeStore.setState({ activeThemeId: null }); });
});
it('shows three cards and a Show all button', () => {
render(<ThemesSettings embedded />);
const total = useThemeStore.getState().installedThemes.length + 1;
expect(total).toBeGreaterThan(3);
expect(cardNames()).toHaveLength(3);
expect(cardNames()[0]).toBe('default_name');
expect(cardNames()).not.toContain('title');
const button = screen.getByTestId('themes-show-all');
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(cardNames()).toHaveLength(total);
expect(button).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(button);
expect(cardNames()).toHaveLength(3);
});
it('keeps the active theme in the collapsed row', () => {
const last = useThemeStore.getState().installedThemes.at(-1)!;
act(() => { useThemeStore.setState({ activeThemeId: last.id }); });
render(<ThemesSettings embedded />);
expect(cardNames()).toHaveLength(3);
expect(cardNames()).toContain(last.name);
});
it('opens for a search result that names a hidden theme', () => {
const last = useThemeStore.getState().installedThemes.at(-1)!;
render(<ThemesSettings embedded />);
expect(cardNames()).not.toContain(last.name);
act(() => { window.dispatchEvent(new CustomEvent('settings-reveal', { detail: { label: last.name } })); });
expect(cardNames()).toContain(last.name);
});
});
+2
View File
@@ -516,6 +516,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
new CustomEvent('settings-plugin-expand', { detail: { pluginId: pendingHighlight.pluginId } })
);
}
// Collapsed lists (the theme gallery) open when the result is inside them.
window.dispatchEvent(new CustomEvent('settings-reveal', { detail: { label: pendingHighlight.label } }));
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
+58 -17
View File
@@ -5,7 +5,11 @@ import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection } from './settings-section';
import { cn } from '@/lib/utils';
import { Check, Lock } from '@/components/icons';
import { Check, Lock, ChevronDown, ChevronUp } from '@/components/icons';
import { Button } from '@/components/ui/button';
/** Cards shown before "Show all": one row of the grid. */
const COLLAPSED_THEME_COUNT = 3;
import { toast } from '@/stores/toast-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -38,6 +42,27 @@ export function ThemesSettings({ embedded }: { embedded?: boolean } = {}) {
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
);
// One row of cards until "Show all": the gallery is long and most people
// never change the theme. The active theme always stays in that row.
const [expanded, setExpanded] = useState(false);
const cards: (typeof visibleThemes[number] | null)[] = [null, ...visibleThemes];
const activeIndex = Math.max(0, cards.findIndex((theme) => (theme?.id ?? null) === activeThemeId));
const collapsed = !expanded && cards.length > COLLAPSED_THEME_COUNT;
const shownCards = collapsed
? (activeIndex < COLLAPSED_THEME_COUNT
? cards.slice(0, COLLAPSED_THEME_COUNT)
: [...cards.slice(0, COLLAPSED_THEME_COUNT - 1), cards[activeIndex]])
: cards;
// A search result for a hidden theme asks the gallery to open first.
useEffect(() => {
const handler = (e: Event) => {
const label = (e as CustomEvent<{ label?: string }>).detail?.label;
if (label && visibleThemes.some((theme) => theme.name === label)) setExpanded(true);
};
window.addEventListener('settings-reveal', handler);
return () => window.removeEventListener('settings-reveal', handler);
}, [visibleThemes]);
useEffect(() => {
if (forcedThemeId && activeThemeId !== forcedThemeId) {
activateTheme(forcedThemeId);
@@ -78,22 +103,24 @@ export function ThemesSettings({ embedded }: { embedded?: boolean } = {}) {
{/* Theme Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* Default theme card */}
<ThemeCard
name={t('default_name')}
author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
disabled={Boolean(forcedThemeId)}
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{visibleThemes.map(theme => {
{shownCards.map((theme) => {
if (!theme) {
return (
<ThemeCard
key="default"
name={t('default_name')}
author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
disabled={Boolean(forcedThemeId)}
onActivate={() => handleActivate(null)}
/>
);
}
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
return (
<ThemeCard
@@ -114,6 +141,20 @@ export function ThemesSettings({ embedded }: { embedded?: boolean } = {}) {
);
})}
</div>
{cards.length > COLLAPSED_THEME_COUNT && (
<Button
variant="ghost"
size="sm"
className="mt-3 text-xs"
aria-expanded={!collapsed}
data-testid="themes-show-all"
onClick={() => setExpanded((v) => !v)}
>
{collapsed ? <ChevronDown className="w-3.5 h-3.5 me-1" /> : <ChevronUp className="w-3.5 h-3.5 me-1" />}
{collapsed ? t('show_all', { count: cards.length }) : t('show_fewer')}
</Button>
)}
</>
);
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "تمت استعادة السمة الافتراضية",
"default_name": "الافتراضية",
"default_badge": "افتراضية",
"admin_enforced": "مفروضة من المسؤول"
"admin_enforced": "مفروضة من المسؤول",
"show_all": "إظهار كل السمات ({count})",
"show_fewer": "إظهار أقل"
},
"tab_groups": {
"apps": "التطبيقات",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "S'ha restaurat el tema predeterminat",
"default_name": "Predeterminat",
"default_badge": "Predeterminat",
"admin_enforced": "Imposat per l'administrador"
"admin_enforced": "Imposat per l'administrador",
"show_all": "Mostra els {count} temes",
"show_fewer": "Mostra'n menys"
},
"tab_groups": {
"apps": "Aplicacions",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Výchozí motiv obnoven",
"default_name": "Výchozí",
"default_badge": "Výchozí",
"admin_enforced": "Vynuceno správcem"
"admin_enforced": "Vynuceno správcem",
"show_all": "Zobrazit všechny motivy ({count})",
"show_fewer": "Zobrazit méně"
},
"tab_groups": {
"apps": "Aplikace",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Standardtema gendannet",
"default_name": "Standard",
"default_badge": "Standard",
"admin_enforced": "Gennemtvunget af administrator"
"admin_enforced": "Gennemtvunget af administrator",
"show_all": "Vis alle {count} temaer",
"show_fewer": "Vis færre"
},
"tab_groups": {
"apps": "Apps",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Standarddesign wiederhergestellt",
"default_name": "Standard",
"default_badge": "Standard",
"admin_enforced": "Vom Administrator erzwungen"
"admin_enforced": "Vom Administrator erzwungen",
"show_all": "Alle {count} Themes anzeigen",
"show_fewer": "Weniger anzeigen"
},
"tab_groups": {
"apps": "Apps",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Default theme restored",
"default_name": "Default",
"default_badge": "Default",
"admin_enforced": "Admin enforced"
"admin_enforced": "Admin enforced",
"show_all": "Show all {count} themes",
"show_fewer": "Show fewer"
},
"tab_groups": {
"apps": "Apps",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Tema predeterminado restaurado",
"default_name": "Predeterminado",
"default_badge": "Predeterminado",
"admin_enforced": "Impuesto por el administrador"
"admin_enforced": "Impuesto por el administrador",
"show_all": "Mostrar los {count} temas",
"show_fewer": "Mostrar menos"
},
"tab_groups": {
"apps": "Aplicaciones",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Vaiketeema taastatud",
"default_name": "Vaikimisi",
"default_badge": "Vaikimisi",
"admin_enforced": "Administraatori määratud"
"admin_enforced": "Administraatori määratud",
"show_all": "Kuva kõik {count} teemat",
"show_fewer": "Kuva vähem"
},
"tab_groups": {
"apps": "Rakendused",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "پوسته پیش‌فرض بازیابی شد",
"default_name": "پیش‌فرض",
"default_badge": "پیش‌فرض",
"admin_enforced": "اجباری از سوی مدیر"
"admin_enforced": "اجباری از سوی مدیر",
"show_all": "نمایش همه {count} پوسته",
"show_fewer": "نمایش کمتر"
},
"tab_groups": {
"apps": "برنامه‌ها",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Thème par défaut restauré",
"default_name": "Par défaut",
"default_badge": "Par défaut",
"admin_enforced": "Imposé par l'administrateur"
"admin_enforced": "Imposé par l'administrateur",
"show_all": "Afficher les {count} thèmes",
"show_fewer": "Afficher moins"
},
"tab_groups": {
"apps": "Applications",
+3 -1
View File
@@ -1012,7 +1012,9 @@
"default_restored": "ערכת הנושא המוגדרת כברירת מחדל שוחזרה",
"default_name": "ברירת מחדל",
"default_badge": "ברירת מחדל",
"admin_enforced": "נאכף על ידי המנהל"
"admin_enforced": "נאכף על ידי המנהל",
"show_all": "הצגת כל {count} ערכות הנושא",
"show_fewer": "הצגת פחות"
},
"tab_groups": {
"apps": "אפליקציות",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Alapértelmezett téma visszaállítva",
"default_name": "Alapértelmezett",
"default_badge": "Alapértelmezett",
"admin_enforced": "Rendszergazda által előírva"
"admin_enforced": "Rendszergazda által előírva",
"show_all": "Mind a(z) {count} téma megjelenítése",
"show_fewer": "Kevesebb megjelenítése"
},
"tab_groups": {
"apps": "Alkalmazások",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Tema predefinito ripristinato",
"default_name": "Predefinito",
"default_badge": "Predefinito",
"admin_enforced": "Imposto dall'amministratore"
"admin_enforced": "Imposto dall'amministratore",
"show_all": "Mostra tutti i {count} temi",
"show_fewer": "Mostra meno"
},
"tab_groups": {
"apps": "Applicazioni",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "デフォルトのテーマに戻しました",
"default_name": "デフォルト",
"default_badge": "デフォルト",
"admin_enforced": "管理者により固定"
"admin_enforced": "管理者により固定",
"show_all": "すべてのテーマ({count}件)を表示",
"show_fewer": "表示を減らす"
},
"tab_groups": {
"apps": "アプリ",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "기본 테마로 복원되었습니다",
"default_name": "기본",
"default_badge": "기본",
"admin_enforced": "관리자가 지정함"
"admin_enforced": "관리자가 지정함",
"show_all": "테마 {count}개 모두 표시",
"show_fewer": "간략히 보기"
},
"tab_groups": {
"apps": "앱",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Noklusējuma tēma atjaunota",
"default_name": "Noklusējuma",
"default_badge": "Noklusējuma",
"admin_enforced": "Noteicis administrators"
"admin_enforced": "Noteicis administrators",
"show_all": "Rādīt visas {count} tēmas",
"show_fewer": "Rādīt mazāk"
},
"tab_groups": {
"apps": "Lietotnes",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Үндсэн сэдвийг сэргээлээ",
"default_name": "Үндсэн",
"default_badge": "Үндсэн",
"admin_enforced": "Администратор тогтоосон"
"admin_enforced": "Администратор тогтоосон",
"show_all": "Бүх {count} загварыг харуулах",
"show_fewer": "Цөөнийг харуулах"
},
"tab_groups": {
"apps": "Програмууд",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Standardtema gjenopprettet",
"default_name": "Standard",
"default_badge": "Standard",
"admin_enforced": "Påtvunget av administrator"
"admin_enforced": "Påtvunget av administrator",
"show_all": "Vis alle {count} temaer",
"show_fewer": "Vis færre"
},
"tab_groups": {
"apps": "Apper",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Standaardthema hersteld",
"default_name": "Standaard",
"default_badge": "Standaard",
"admin_enforced": "Afgedwongen door beheerder"
"admin_enforced": "Afgedwongen door beheerder",
"show_all": "Alle {count} thema's tonen",
"show_fewer": "Minder tonen"
},
"tab_groups": {
"apps": "Apps",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Przywrócono domyślny motyw",
"default_name": "Domyślny",
"default_badge": "Domyślny",
"admin_enforced": "Wymuszony przez administratora"
"admin_enforced": "Wymuszony przez administratora",
"show_all": "Pokaż wszystkie motywy ({count})",
"show_fewer": "Pokaż mniej"
},
"tab_groups": {
"apps": "Aplikacje",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Tema padrão restaurado",
"default_name": "Padrão",
"default_badge": "Padrão",
"admin_enforced": "Imposto pelo administrador"
"admin_enforced": "Imposto pelo administrador",
"show_all": "Mostrar todos os {count} temas",
"show_fewer": "Mostrar menos"
},
"tab_groups": {
"apps": "Aplicativos",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Tema implicită a fost restabilită",
"default_name": "Implicită",
"default_badge": "Implicită",
"admin_enforced": "Impusă de administrator"
"admin_enforced": "Impusă de administrator",
"show_all": "Afișează toate cele {count} teme",
"show_fewer": "Afișează mai puține"
},
"tab_groups": {
"apps": "Aplicații",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Тема по умолчанию восстановлена",
"default_name": "По умолчанию",
"default_badge": "По умолчанию",
"admin_enforced": "Задано администратором"
"admin_enforced": "Задано администратором",
"show_all": "Показать все темы ({count})",
"show_fewer": "Показать меньше"
},
"tab_groups": {
"apps": "Приложения",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "Predvolený motív bol obnovený",
"default_name": "Predvolený",
"default_badge": "Predvolený",
"admin_enforced": "Vynútené správcom"
"admin_enforced": "Vynútené správcom",
"show_all": "Zobraziť všetky motívy ({count})",
"show_fewer": "Zobraziť menej"
},
"tab_groups": {
"apps": "Aplikácie",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Varsayılan tema geri yüklendi",
"default_name": "Varsayılan",
"default_badge": "Varsayılan",
"admin_enforced": "Yönetici tarafından zorunlu"
"admin_enforced": "Yönetici tarafından zorunlu",
"show_all": "Tüm {count} temayı göster",
"show_fewer": "Daha az göster"
},
"tab_groups": {
"apps": "Uygulamalar",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "Тему за замовчуванням відновлено",
"default_name": "За замовчуванням",
"default_badge": "За замовчуванням",
"admin_enforced": "Встановлено адміністратором"
"admin_enforced": "Встановлено адміністратором",
"show_all": "Показати всі теми ({count})",
"show_fewer": "Показати менше"
},
"tab_groups": {
"apps": "програми",
+3 -1
View File
@@ -1050,7 +1050,9 @@
"default_restored": "已還原預設主題",
"default_name": "預設",
"default_badge": "預設",
"admin_enforced": "管理員強制"
"admin_enforced": "管理員強制",
"show_all": "顯示全部 {count} 個主題",
"show_fewer": "收合"
},
"tab_groups": {
"apps": "應用程式",
+3 -1
View File
@@ -1047,7 +1047,9 @@
"default_restored": "已恢复默认主题",
"default_name": "默认",
"default_badge": "默认",
"admin_enforced": "管理员强制"
"admin_enforced": "管理员强制",
"show_all": "显示全部 {count} 个主题",
"show_fewer": "收起"
},
"tab_groups": {
"apps": "应用",