feat: add window size classes for tablet and landscape layouts

This commit is contained in:
Linus Rath
2026-10-15 01:44:45 +02:00
parent 2a47913d70
commit 4b29cdfd3c
2 changed files with 99 additions and 0 deletions
+52
View File
@@ -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);
});
});
+47
View File
@@ -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;