diff --git a/src/lib/__tests__/use-layout.test.ts b/src/lib/__tests__/use-layout.test.ts new file mode 100644 index 00000000..13746b91 --- /dev/null +++ b/src/lib/__tests__/use-layout.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 411, height: 914 }) })); + +import { layoutFor, listPaneWidth } from '../use-layout'; + +describe('layoutFor', () => { + it('keeps a phone in portrait on the single-pane layout', () => { + expect(layoutFor(411, 914)).toEqual({ + widthClass: 'compact', shortHeight: false, isSplit: false, hasSidebar: false, + }); + }); + + it('keeps a phone in landscape single-pane although it is wide', () => { + const l = layoutFor(914, 411); + expect(l.widthClass).toBe('expanded'); + expect(l.shortHeight).toBe(true); + expect(l.isSplit).toBe(false); + expect(l.hasSidebar).toBe(false); + }); + + it('splits a tablet in portrait without a sidebar', () => { + expect(layoutFor(800, 1280)).toEqual({ + widthClass: 'medium', shortHeight: false, isSplit: true, hasSidebar: false, + }); + }); + + it('splits a tablet in landscape and adds the sidebar', () => { + expect(layoutFor(1280, 800)).toEqual({ + widthClass: 'expanded', shortHeight: false, isSplit: true, hasSidebar: true, + }); + }); + + it('uses the window, so a narrow split-screen window is compact', () => { + expect(layoutFor(500, 800).isSplit).toBe(false); + }); + + it('puts the breakpoints at 600 and 840', () => { + expect(layoutFor(599, 900).widthClass).toBe('compact'); + expect(layoutFor(600, 900).widthClass).toBe('medium'); + expect(layoutFor(839, 900).widthClass).toBe('medium'); + expect(layoutFor(840, 900).widthClass).toBe('expanded'); + }); +}); + +describe('listPaneWidth', () => { + it('stays between 320 and 420 dp', () => { + expect(listPaneWidth(600)).toBe(320); + expect(listPaneWidth(1000)).toBe(340); + expect(listPaneWidth(2000)).toBe(420); + }); +}); diff --git a/src/lib/use-layout.ts b/src/lib/use-layout.ts new file mode 100644 index 00000000..eb77de83 --- /dev/null +++ b/src/lib/use-layout.ts @@ -0,0 +1,47 @@ +import { useWindowDimensions } from 'react-native'; + +/** + * Window size classes for the adaptive layouts (docs/ux-audit-2026-10.md, + * "Adaptive layout plan"). They come from the window, not the device, so + * split-screen, freeform windows and foldables get the layout that fits. + */ +export type WidthClass = 'compact' | 'medium' | 'expanded'; + +export const MEDIUM_MIN_WIDTH = 600; +export const EXPANDED_MIN_WIDTH = 840; +/** Below this a window is "short": a phone in landscape. */ +export const SHORT_MAX_HEIGHT = 480; + +export interface Layout { + widthClass: WidthClass; + /** A phone in landscape: compress chrome, keep the single-pane layout. */ + shortHeight: boolean; + /** List and detail side by side (medium or expanded, and not short). */ + isSplit: boolean; + /** A persistent sidebar next to the list (expanded and not short). */ + hasSidebar: boolean; +} + +export function layoutFor(width: number, height: number): Layout { + const widthClass: WidthClass = + width >= EXPANDED_MIN_WIDTH ? 'expanded' : width >= MEDIUM_MIN_WIDTH ? 'medium' : 'compact'; + const shortHeight = height < SHORT_MAX_HEIGHT; + const isSplit = widthClass !== 'compact' && !shortHeight; + return { widthClass, shortHeight, isSplit, hasSidebar: isSplit && widthClass === 'expanded' }; +} + +export function useLayout(): Layout { + const { width, height } = useWindowDimensions(); + return layoutFor(width, height); +} + +/** + * Width of the list pane next to a detail pane: a third of the window, + * between 320 and 420 dp (the webmail's list column is about 400). + */ +export function listPaneWidth(windowWidth: number): number { + return Math.round(Math.max(320, Math.min(420, windowWidth * 0.34))); +} + +/** Width for sheets, dialogs and floating cards that would span a wide window. */ +export const SHEET_MAX_WIDTH = 560;