Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ Search, sidebar, split view, swipe actions, glass and SF Symbols keep capability
/>
```

An optional `list` pane adds AppKit's content-list column between sidebar and content (the Mail layout: mailboxes | messages | message) inside the same native split view, with `listWidth` and `listMinWidth`. Prefer it over nesting a second `SidebarSplitView` in `content`: a nested split only learns its frame after the outer split's metrics round-trip through React, so every outer divider drag resizes it a commit late and its panes tear. Sidebar and list hold their widths when the window resizes; the content column absorbs the change. `onResize` adds `listWidth`, `listHeight` and `listX` (zero without a list).

Preferred widths (`sidebarWidth`, `listWidth`) seed the dividers on the first layout with enough room for them and again when those props, `list` presence or `sidebarCollapsed` change. Afterwards a divider stays where the user dragged it across window resizes and re-layouts. Each native layout pass publishes one resize event with its final pane sizes. Content and list pane backgrounds are opaque in the semantic window background color (including the content pane in two-pane layouts); content is clipped to its pane. Pane content still receives its new React layout on the next commit.

`onResize` reports owned pane dimensions, `contentX`, total `height`, and `phase: 'provisional' | 'ready'`. Both phases update layout. Zero dimensions remain zero, so collapsed panes cannot inherit stale sizes. Initial metrics are a mount-time hint; readiness comes from native layout. Title-bar overlay colors use `#RRGGBB` or `#RRGGBBAA`, with opacity in `[0, 1]` (default 1 when an overlay is supplied). Application-specific chrome presets are not exported.

`GlassView` under `/ui/glass` is the single native glass container, with `glassStyle: 'regular' | 'clear'` and RN `ColorValue` tint. It replaces the overlapping GlassEffectView/GlassSurface wrappers and requires macOS 26; older systems preserve children without an effect and report the host restriction. Colors use RN's standard conversion, including its dynamic/system representations.
Expand Down
289 changes: 263 additions & 26 deletions packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type SidebarSplitViewResizeEvent = Readonly<{
height: Double;
isLayoutReady: boolean;
isVertical: boolean;
listHeight: Double;
listWidth: Double;
listX: Double;
sidebarHeight: Double;
sidebarWidth: Double;
}>;
Expand All @@ -20,6 +23,9 @@ export interface NativeProps extends ViewProps {
contentTitlebarOverlayColor?: string;
contentTitlebarOverlayOpacity?: Double;
contentMinWidth?: Double;
hasList?: boolean;
listMinWidth?: Double;
listWidth?: Double;
onSplitViewDidResize?: DirectEventHandler<SidebarSplitViewResizeEvent>;
sidebarCollapsed?: boolean;
sidebarMinWidth?: Double;
Expand Down
31 changes: 20 additions & 11 deletions packages/ui/src/appkit-split-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { appearance, callback, finite, macosViewAvailability, type SpecializedVi
import NativeSplitView from "./SidebarSplitViewNativeComponent";
export type SidebarSplitViewAppearance = "system" | "light" | "dark";
export type SidebarSplitViewTitlebarMaterial = "none" | "glass" | "titlebar" | "headerView" | "hudWindow" | "sidebar" | "windowBackground";
export interface SidebarSplitViewPaneMetrics { contentHeight: number; contentWidth: number; sidebarHeight: number; sidebarWidth: number }
export interface SidebarSplitViewResizeEvent extends SidebarSplitViewPaneMetrics { contentX: number; height: number; phase: "provisional" | "ready" }
export interface SidebarSplitViewPaneMetrics { contentHeight: number; contentWidth: number; sidebarHeight: number; sidebarWidth: number; listHeight?: number; listWidth?: number }
export interface SidebarSplitViewResizeEvent extends SidebarSplitViewPaneMetrics { listHeight: number; listWidth: number; contentX: number; listX: number; height: number; phase: "provisional" | "ready" }
export interface SplitViewTitleBarOverlay { color: string; opacity?: number }
export interface SplitViewTitleBarOptions {
content?: { height?: number; material?: SidebarSplitViewTitlebarMaterial; overlay?: SplitViewTitleBarOverlay };
Expand All @@ -16,11 +16,15 @@ export interface SplitViewTitleBarOptions {
export interface SidebarSplitViewProps extends Omit<SpecializedViewProps, "children"> {
sidebar: ReactNode;
content: ReactNode;
/** Optional middle column (AppKit content list, as in Mail): sidebar | list | content in one native split. */
list?: ReactNode;
children?: never;
appearance?: SidebarSplitViewAppearance;
contentMinWidth?: number;
sidebarMinWidth?: number;
sidebarWidth?: number;
listMinWidth?: number;
listWidth?: number;
sidebarCollapsed?: boolean;
/** Used only for initial layout, before native measurements arrive. */
initialPaneMetrics?: SidebarSplitViewPaneMetrics;
Expand All @@ -31,7 +35,9 @@ export function getSplitViewAvailability() { return macosViewAvailability("Sideb
function metrics(value: SidebarSplitViewPaneMetrics) {
if (!value || typeof value !== "object") throw new SparkError("E_INVALID_ARGUMENT", "Expected pane metrics");
for (const key of ["contentHeight", "contentWidth", "sidebarHeight", "sidebarWidth"] as const) finite(value[key], key);
for (const key of ["listHeight", "listWidth"] as const) if (value[key] !== undefined) finite(value[key], key);
}
const paneKeys = ["contentHeight", "contentWidth", "sidebarHeight", "sidebarWidth", "listHeight", "listWidth"] as const;
function keys(value: unknown, allowed: readonly string[]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new SparkError("E_INVALID_ARGUMENT", "Expected title-bar options");
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new SparkError("E_UNSUPPORTED_OPTION", `Unknown title-bar option: ${key}`);
Expand All @@ -42,9 +48,11 @@ function overlay(value?: SplitViewTitleBarOverlay) {
if (typeof value.color !== "string" || !/^#(?:[\da-f]{6}|[\da-f]{8})$/i.test(value.color)) throw new SparkError("E_INVALID_ARGUMENT", "Expected #RRGGBB or #RRGGBBAA title-bar color");
if (value.opacity !== undefined) finite(value.opacity, "overlay opacity", 0, 1);
}
export function SidebarSplitView({ sidebar, content, children, appearance: theme = "system", contentMinWidth = 320, sidebarMinWidth = 180, sidebarWidth, sidebarCollapsed = false, initialPaneMetrics, titleBar = {}, onResize, ref, onError, ...props }: SidebarSplitViewProps) {
appearance(theme); finite(contentMinWidth, "minimum content width"); finite(sidebarMinWidth, "minimum sidebar width");
export function SidebarSplitView({ sidebar, content, list, children, appearance: theme = "system", contentMinWidth = 320, sidebarMinWidth = 180, sidebarWidth, listMinWidth = 240, listWidth, sidebarCollapsed = false, initialPaneMetrics, titleBar = {}, onResize, ref, onError, ...props }: SidebarSplitViewProps) {
appearance(theme); finite(contentMinWidth, "minimum content width"); finite(sidebarMinWidth, "minimum sidebar width"); finite(listMinWidth, "minimum list width");
if (sidebarWidth !== undefined) finite(sidebarWidth, "sidebar width", sidebarMinWidth);
if (listWidth !== undefined) finite(listWidth, "list width", listMinWidth);
const hasList = list !== undefined && list !== null && typeof list !== "boolean" && list !== "";
if (typeof sidebarCollapsed !== "boolean" || children !== undefined) throw new SparkError("E_INVALID_ARGUMENT", "Use named sidebar/content panes and a boolean collapsed state");
if (initialPaneMetrics !== undefined) metrics(initialPaneMetrics);
keys(titleBar, ["content", "sidebar"]);
Expand All @@ -56,22 +64,23 @@ export function SidebarSplitView({ sidebar, content, children, appearance: theme
callback(onResize, "resize");
const control = useControl({ ref, onError }, getSplitViewAvailability());
const [paneMetrics, setPaneMetrics] = useState<SidebarSplitViewPaneMetrics | undefined>(() => initialPaneMetrics ? { ...initialPaneMetrics } : undefined);
function resize(event: NativeSyntheticEvent<SidebarSplitViewPaneMetrics & { contentX: number; height: number; isLayoutReady: boolean }>) {
function resize(event: NativeSyntheticEvent<SidebarSplitViewPaneMetrics & { contentX: number; listX: number; height: number; isLayoutReady: boolean }>) {
if (!control.active()) return;
let value: SidebarSplitViewResizeEvent;
try {
const data = event?.nativeEvent; metrics(data); finite(data.contentX, "content x", -Infinity); finite(data.height, "height");
const data = event?.nativeEvent; metrics(data); finite(data.listHeight, "list height"); finite(data.listWidth, "list width"); finite(data.contentX, "content x", -Infinity); finite(data.listX, "list x", -Infinity); finite(data.height, "height");
if (typeof data.isLayoutReady !== "boolean") throw new Error("Expected layout readiness");
value = { contentHeight: data.contentHeight, contentWidth: data.contentWidth, sidebarHeight: data.sidebarHeight, sidebarWidth: data.sidebarWidth, contentX: data.contentX, height: data.height, phase: data.isLayoutReady ? "ready" : "provisional" };
value = { contentHeight: data.contentHeight, contentWidth: data.contentWidth, sidebarHeight: data.sidebarHeight, sidebarWidth: data.sidebarWidth, listHeight: data.listHeight, listWidth: data.listWidth, contentX: data.contentX, listX: data.listX, height: data.height, phase: data.isLayoutReady ? "ready" : "provisional" };
} catch (cause) { control.error(new SparkError("E_INVALID_DATA", "Invalid split-view layout", { cause })); return; }
setPaneMetrics(current => current && ["contentHeight", "contentWidth", "sidebarHeight", "sidebarWidth"].every(key => current[key as keyof SidebarSplitViewPaneMetrics] === value[key as keyof SidebarSplitViewPaneMetrics]) ? current : value);
setPaneMetrics(current => current && paneKeys.every(key => current[key] === value[key]) ? current : value);
onResize?.(value);
}
if (control.failed) return <View {...props} ref={control.ref} style={[{ flexDirection: "row" }, props.style]}>{sidebarCollapsed ? null : sidebar}{content}</View>;
return <NativeSplitView {...props} ref={control.ref} appearance={theme} contentMinWidth={contentMinWidth} sidebarMinWidth={sidebarMinWidth} sidebarWidth={sidebarWidth} sidebarCollapsed={sidebarCollapsed} onSplitViewDidResize={resize}
if (control.failed) return <View {...props} ref={control.ref} style={[{ flexDirection: "row" }, props.style]}>{sidebarCollapsed ? null : sidebar}{list}{content}</View>;
return <NativeSplitView {...props} ref={control.ref} appearance={theme} contentMinWidth={contentMinWidth} sidebarMinWidth={sidebarMinWidth} sidebarWidth={sidebarWidth} hasList={hasList} listMinWidth={listMinWidth} listWidth={listWidth} sidebarCollapsed={sidebarCollapsed} onSplitViewDidResize={resize}
contentTitlebarHeight={titleBar.content?.height ?? 0} contentTitlebarMaterial={material} contentTitlebarOverlayColor={titleBar.content?.overlay?.color} contentTitlebarOverlayOpacity={titleBar.content?.overlay?.opacity ?? (titleBar.content?.overlay ? 1 : 0)}
sidebarTitlebarOverlayColor={titleBar.sidebar?.overlay?.color} sidebarTitlebarOverlayOpacity={titleBar.sidebar?.overlay?.opacity ?? (titleBar.sidebar?.overlay ? 1 : 0)}>
<View key="sidebar" style={{ position: "absolute", top: 0, left: 0, minWidth: 0, height: paneMetrics?.sidebarHeight, width: sidebarCollapsed ? 0 : paneMetrics?.sidebarWidth ?? sidebarWidth ?? sidebarMinWidth }}>{sidebar}</View>
<View key="content" style={{ position: "absolute", top: 0, left: 0, minWidth: 0, height: paneMetrics?.contentHeight, width: paneMetrics?.contentWidth }}>{content}</View>
<View key="content" style={{ position: "absolute", top: 0, left: 0, minWidth: 0, overflow: "hidden", height: paneMetrics?.contentHeight, width: paneMetrics?.contentWidth }}>{content}</View>
{hasList ? <View key="list" style={{ position: "absolute", top: 0, left: 0, minWidth: 0, height: paneMetrics?.listHeight, width: paneMetrics?.listWidth ?? listWidth ?? listMinWidth }}>{list}</View> : null}
</NativeSplitView>;
}
27 changes: 27 additions & 0 deletions tests/split-view-layout-native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";

test.skipIf(process.platform !== "darwin")("native split views preserve divider ownership and final pane metrics", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "spark-split-view-test-"));
try {
const root = path.resolve(import.meta.dirname, "..");
const source = readFileSync(path.join(root, "packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm"), "utf8");
const method = (name: string) => {
const start = source.indexOf(name);
if (start < 0) throw new Error(`Missing split-view method: ${name}`);
return source.slice(start, source.indexOf("\n}\n", start) + 3);
};
const classes = source.slice(source.indexOf("@interface RNSidebarSplitViewNativeSplitView"), source.indexOf("static char RNSidebarSplitViewStartupKey;"));
const methods = ["- (void)mountChildComponentView:", "- (void)unmountChildComponentView:", "- (void)syncReactSubview:", "- (void)splitViewDidResize", "- (void)syncReactSubviewFrames", "- (void)updateSplitItemSizing", "- (void)updateListItem", "- (CGFloat)listReserveWidth", "- (CGFloat)preferredSidebarWidthForBounds:", "- (CGFloat)preferredListWidthForBounds:", "- (void)updateSidebarCollapsed", "- (CGRect)currentLayoutBounds", "- (void)applyDividerPositionForBounds:", "- (void)layoutSplitView", "- (BOOL)applyEstimatedSplitViewLayoutForBounds:", "- (void)publishSplitViewLayoutAllowEstimatedReady:"];
const input = path.join(directory, "test.mm"), output = path.join(directory, "test");
const fixture = readFileSync(path.join(root, "tests/split-view-layout.native.mm"), "utf8");
writeFileSync(input, fixture.replace("// ACTUAL_CLASSES", classes).replace("// ACTUAL_METHODS", methods.map(method).join("\n")));
execFileSync("clang++", ["-fobjc-arc", "-fblocks", "-std=c++20", "-framework", "AppKit", "-framework", "QuartzCore", input, "-o", output]);
for (const scenario of ["anchor", "metrics", "events", "clamp", "resize", "ownership", "toggle", "mount"]) {
expect(execFileSync(output, [scenario], { encoding: "utf8", timeout: 15000 })).toContain("Native split-view probe passed");
}
} finally { rmSync(directory, { recursive: true, force: true }); }
}, 30000);
Loading
Loading