diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index 7ce12389c..a43d8368d 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -91,3 +91,41 @@ animation: none; } } + +/* settings */ + +/* The open settings page in the skeleton nav: the row whose + data-sk-settings-tab matches data-boot-settings-tab. */ +@custom-variant boot-settings-active (&:where( + [data-boot-settings-tab="account"] [data-sk-settings-tab="account"], + [data-boot-settings-tab="general"] [data-sk-settings-tab="general"], + [data-boot-settings-tab="notifications"] [data-sk-settings-tab="notifications"], + [data-boot-settings-tab="appearance"] [data-sk-settings-tab="appearance"], + [data-boot-settings-tab="accessibility"] [data-sk-settings-tab="accessibility"], + [data-boot-settings-tab="message_list"] [data-sk-settings-tab="message_list"], + [data-boot-settings-tab="reading"] [data-sk-settings-tab="reading"], + [data-boot-settings-tab="composing"] [data-sk-settings-tab="composing"], + [data-boot-settings-tab="vacation"] [data-sk-settings-tab="vacation"], + [data-boot-settings-tab="filters"] [data-sk-settings-tab="filters"], + [data-boot-settings-tab="organize"] [data-sk-settings-tab="organize"], + [data-boot-settings-tab="security"] [data-sk-settings-tab="security"], + [data-boot-settings-tab="sharing"] [data-sk-settings-tab="sharing"], + [data-boot-settings-tab="recovery"] [data-sk-settings-tab="recovery"], + [data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"], + [data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"], + [data-boot-settings-tab="files"] [data-sk-settings-tab="files"], + [data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"], + [data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"] +)); + +/* Settings pages the nav listed last time (bulwark-boot-hints). Without + hints every page is drawn except recovery (an Enterprise feature). */ +@custom-variant boot-no-settings-security (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="security"]) *)); +@custom-variant boot-no-settings-sharing (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="sharing"]) *)); +@custom-variant boot-no-settings-filters (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="filters"]) *)); +@custom-variant boot-no-settings-vacation (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="vacation"]) *)); +@custom-variant boot-no-settings-calendar (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="calendar"]) *)); +@custom-variant boot-no-settings-contacts (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="contacts"]) *)); +@custom-variant boot-no-settings-files (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="files"]) *)); +@custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *)); +@custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *)); diff --git a/components/boot-skeleton/__tests__/settings-skeleton.test.tsx b/components/boot-skeleton/__tests__/settings-skeleton.test.tsx new file mode 100644 index 000000000..d6d5e02f2 --- /dev/null +++ b/components/boot-skeleton/__tests__/settings-skeleton.test.tsx @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { SettingsSkeleton, SettingsSkeletonBody } from '../settings-skeleton'; +import { SETTINGS_TAB_IDS } from '@/lib/settings-tabs'; + +/** + * The settings skeleton marks the open page and hides the pages the last + * session's nav didn't have, both from attributes the boot script sets on + * . The selectors live in app/boot-skeleton.css as Tailwind variants; + * read them from there and run them against the rendered rows. + */ +const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8'); + +/** The selector inside `@custom-variant (&:where())`. */ +function variantSelector(name: string): string { + const match = css.match(new RegExp(`@custom-variant ${name} \\(&:where\\(([\\s\\S]*?)\\)\\);`)); + if (!match) throw new Error(`no variant ${name}`); + return match[1].trim(); +} + +function mount(html: string) { + document.body.innerHTML = html; + return document.body; +} + +afterEach(() => { + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-boot-settings-tab'); + document.documentElement.removeAttribute('data-boot-settings-tabs'); +}); + +describe('settings skeleton nav', () => { + it('draws a row for every settings page', () => { + const root = mount(renderToString()); + const ids = [...root.querySelectorAll('[data-sk-settings-tab]')].map((el) => el.getAttribute('data-sk-settings-tab')); + expect([...ids].sort()).toEqual([...SETTINGS_TAB_IDS].sort()); + }); + + it.each(SETTINGS_TAB_IDS)('highlights only the %s row when that page is open', (tab) => { + const root = mount(renderToString()); + const active = variantSelector('boot-settings-active'); + document.documentElement.setAttribute('data-boot-settings-tab', tab); + const rows = [...root.querySelectorAll('[data-sk-settings-tab]')]; + const matching = rows.filter((row) => row.matches(active)).map((row) => row.getAttribute('data-sk-settings-tab')); + expect(matching).toEqual([tab]); + const row = rows.find((r) => r.getAttribute('data-sk-settings-tab') === tab)!; + expect(row.className).toContain('boot-settings-active:bg-accent'); + }); + + it('hides the optional pages the last nav did not list, and shows recovery only when it did', () => { + const root = mount(renderToString()); + const row = (tab: string) => root.querySelector(`[data-sk-settings-tab="${tab}"]`)!; + const hidesFilters = variantSelector('boot-no-settings-filters'); + const showsRecovery = variantSelector('boot-settings-recovery'); + expect(row('filters').className).toContain('boot-no-settings-filters:hidden'); + expect(row('recovery').className).toContain('boot-settings-recovery:flex'); + + // No hints: everything but recovery. + expect(row('filters').matches(hidesFilters)).toBe(false); + expect(row('recovery').matches(showsRecovery)).toBe(false); + + document.documentElement.setAttribute('data-boot-settings-tabs', 'account appearance recovery'); + expect(row('filters').matches(hidesFilters)).toBe(true); + expect(row('recovery').matches(showsRecovery)).toBe(true); + + document.documentElement.setAttribute('data-boot-settings-tabs', 'account filters'); + expect(row('filters').matches(hidesFilters)).toBe(false); + expect(row('recovery').matches(showsRecovery)).toBe(false); + }); + + it('has the back-to-mail box only outside a Pro pane', () => { + const page = mount(renderToString()); + expect(page.querySelector('.boot-pro\\:hidden')).not.toBeNull(); + const pane = mount(renderToString()); + expect(pane.querySelector('.boot-pro\\:hidden')).toBeNull(); + // A Pro pane is desktop only: no phone list. + expect(pane.querySelector('.boot-open\\:hidden')).toBeNull(); + }); +}); diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index 006136b27..4dc84061d 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -1,3 +1,8 @@ +import { ArrowLeft, ChevronRight, LogOut, MoreVertical, Search, Settings } from "@/components/icons"; +import { SETTINGS_NAV_GROUPS, SETTINGS_TAB_ICONS } from "@/components/settings/settings-tab-icons"; +import type { SettingsSearchTab } from "@/lib/settings-search"; +import { cn } from "@/lib/cn"; +import { Bar, Block, Circle, repeat, textWidth } from "./kit"; import { AppShellSkeleton, type RailLabels } from "./shell"; export interface SettingsSkeletonProps { @@ -8,18 +13,287 @@ export interface SettingsSkeletonProps { } /** - * The settings page's own content: everything in the row next to the rail. - * `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which - * has no rail, no Mountain View shell and fills its pane (h-full). + * Rows the nav may leave out, hidden when the last session's nav didn't have + * them (`data-boot-settings-tabs`, app/boot-skeleton.css). Calendar and files + * also follow the rail's hints. Recovery is an Enterprise feature, so it is + * only drawn when the hints say it was there. */ -export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) { - return
; +const OPTIONAL: Partial> = { + security: "boot-no-settings-security:hidden", + sharing: "boot-no-settings-sharing:hidden", + filters: "boot-no-settings-filters:hidden", + vacation: "boot-no-settings-vacation:hidden", + recovery: "hidden boot-settings-recovery:flex", + calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden", + contacts: "boot-no-settings-contacts:hidden", + files: "boot-no-files:hidden boot-no-settings-files:hidden", + addons: "boot-no-settings-addons:hidden", +}; + +/** A line of text as tall as its line box, so rows keep the real height. */ +function Line({ className, bar, w }: { className?: string; bar: string; w: string }) { + return ( + + + + ); } -/** Skeleton of the settings page. Stub: filled in per surface. */ +/** The search field (ToolbarSearch): an h-8 input with the magnifier at its start. */ +function SearchField() { + return ( + + + + + ); +} + +/** The Advanced switch row at the foot of the nav (AdvancedSettingsSwitch). */ +function AdvancedRow() { + return ( +
+ + +
+ ); +} + +/** The desktop nav (settings-app.tsx, "Desktop layout"). */ +function SettingsNav({ embedded }: { embedded: boolean }) { + let row = 0; + return ( +