diff --git a/docs/ui.md b/docs/ui.md index 94ba706f..1694f7c9 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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. diff --git a/packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm b/packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm index d17d4378..e7188018 100644 --- a/packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm +++ b/packages/ui/macos/appkit-split-view/RNSidebarSplitViewComponent.mm @@ -203,6 +203,73 @@ static void RNSidebarSplitViewApplyColorOverlay(NSView *view, NSColor *color, CG } } +// Pane containers are transparent, so a thin divider showed the window's (white) background through it, +// which is invisible in light mode and a bright stripe in dark mode. Paint it opaquely with semantic colors +// that resolve against the drawing appearance. +@interface RNSidebarSplitViewNativeSplitView : NSSplitView +/// YES while the user drags a divider. +@property (nonatomic, readonly) BOOL trackingDivider; +@end + +@implementation RNSidebarSplitViewNativeSplitView + +// NSSplitView only receives mouseDown: on a divider, and tracks the whole drag inside it. +- (void)mouseDown:(NSEvent *)event +{ + _trackingDivider = YES; + [super mouseDown:event]; + _trackingDivider = NO; +} + +// While a divider drags, panes resize under a still cursor and pane edges pass beneath it. React views take +// hover from a window hit test, so claiming the hit here keeps rows from flickering hover states (and starting +// their hover animations) every frame of the drag. +- (NSView *)hitTest:(NSPoint)point +{ + if (_trackingDivider) { + return NSPointInRect([self convertPoint:point fromView:self.superview], self.bounds) ? self : nil; + } + return [super hitTest:point]; +} + +- (NSColor *)dividerColor +{ + return NSColor.separatorColor; +} + +- (void)drawDividerInRect:(NSRect)rect +{ + [NSColor.windowBackgroundColor setFill]; + NSRectFill(rect); + [self.dividerColor setFill]; + NSRectFillUsingOperation(rect, NSCompositingOperationSourceOver); +} +@end + +// List and content panes are opaque in the window background color, as AppKit panes are. React content +// follows a native resize one commit later; until then the newly exposed strip matches the window instead +// of showing a white stripe. The sidebar container stays clear so its native material shows. +@interface RNSidebarSplitViewPaneView : NSView +@end + +@implementation RNSidebarSplitViewPaneView +- (BOOL)wantsUpdateLayer +{ + return YES; +} + +- (void)updateLayer +{ + self.layer.backgroundColor = NSColor.windowBackgroundColor.CGColor; +} + +- (void)viewDidChangeEffectiveAppearance +{ + [super viewDidChangeEffectiveAppearance]; + self.needsDisplay = YES; +} +@end + static char RNSidebarSplitViewStartupKey; // Startup must stay entirely in AppKit: constructing a Fabric view here would @@ -356,22 +423,35 @@ @implementation RNSidebarSplitViewComponent { NSSplitViewController *_splitViewController; NSViewController *_sidebarViewController; NSViewController *_contentViewController; + NSViewController *_listViewController; NSSplitViewItem *_sidebarItem; NSSplitViewItem *_contentItem; + NSSplitViewItem *_listItem; NSView *_sidebarContainer; NSView *_contentContainer; + NSView *_listContainer; RCTUIView *_sidebarReactView; RCTUIView *_contentReactView; + RCTUIView *_listReactView; id _resizeObserver; LayoutMetrics _currentLayoutMetrics; LayoutMetrics _sidebarReactLayoutMetrics; LayoutMetrics _contentReactLayoutMetrics; + LayoutMetrics _listReactLayoutMetrics; CGFloat _sidebarMinWidth; CGFloat _sidebarWidth; CGFloat _contentMinWidth; + CGFloat _listMinWidth; + CGFloat _listWidth; + BOOL _hasList; BOOL _sidebarCollapsed; + // Preferred widths seed the dividers once; afterwards the user's drag owns them. + BOOL _needsPreferredDividerPositions; + // Our own layout pass resizes subviews several times; only its final state is published. + BOOL _layingOutSplitView; CGFloat _lastSidebarWidth; CGFloat _lastContentWidth; + CGFloat _lastListWidth; CGFloat _lastHeight; BOOL _lastLayoutReady; BOOL _didPublishMount; @@ -401,12 +481,19 @@ - (instancetype)initWithFrame:(CGRect)frame _currentLayoutMetrics = EmptyLayoutMetrics; _sidebarReactLayoutMetrics = EmptyLayoutMetrics; _contentReactLayoutMetrics = EmptyLayoutMetrics; + _listReactLayoutMetrics = EmptyLayoutMetrics; _sidebarMinWidth = 180; _sidebarWidth = 0; _contentMinWidth = 320; + _listMinWidth = 240; + _listWidth = 0; + _hasList = NO; _sidebarCollapsed = NO; + _needsPreferredDividerPositions = YES; + _layingOutSplitView = NO; _lastSidebarWidth = -1; _lastContentWidth = -1; + _lastListWidth = -1; _lastHeight = -1; _lastLayoutReady = NO; _appearanceName = @"system"; @@ -415,33 +502,47 @@ - (instancetype)initWithFrame:(CGRect)frame _contentTitlebarOverlayOpacity = 0; _sidebarTitlebarOverlayOpacity = 0; _sidebarContainer = [NSView new]; - _contentContainer = [NSView new]; + _contentContainer = [RNSidebarSplitViewPaneView new]; + _listContainer = [RNSidebarSplitViewPaneView new]; _sidebarContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; _contentContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + _listContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; _sidebarContainer.wantsLayer = YES; _contentContainer.wantsLayer = YES; + _listContainer.wantsLayer = YES; _sidebarContainer.layer.masksToBounds = NO; _contentContainer.layer.masksToBounds = NO; + _listContainer.layer.masksToBounds = YES; _sidebarContainer.layer.zPosition = 10; _contentContainer.layer.zPosition = 0; + _listContainer.layer.zPosition = 0; _sidebarViewController = [NSViewController new]; _contentViewController = [NSViewController new]; + _listViewController = [NSViewController new]; _sidebarViewController.view = _sidebarContainer; _contentViewController.view = _contentContainer; + _listViewController.view = _listContainer; _sidebarItem = [NSSplitViewItem sidebarWithViewController:_sidebarViewController]; _contentItem = [NSSplitViewItem splitViewItemWithViewController:_contentViewController]; + // Mail-style middle column. Inserted between sidebar and content only while a list pane is supplied. + _listItem = [NSSplitViewItem contentListWithViewController:_listViewController]; _sidebarItem.canCollapse = YES; _contentItem.canCollapse = NO; + _listItem.canCollapse = NO; + // Window resizes go to the content column; sidebar (260) and list keep their widths. + _listItem.holdingPriority = NSLayoutPriorityDefaultLow + 1; [self updateSplitItemSizing]; if (@available(macOS 11.0, *)) { _sidebarItem.allowsFullHeightLayout = YES; _contentItem.allowsFullHeightLayout = YES; + _listItem.allowsFullHeightLayout = YES; } _splitViewController = [NSSplitViewController new]; + _splitViewController.splitView = [RNSidebarSplitViewNativeSplitView new]; _splitViewController.minimumThicknessForInlineSidebars = 0; _splitViewController.splitView.vertical = YES; _splitViewController.splitView.dividerStyle = NSSplitViewDividerStyleThin; @@ -450,12 +551,13 @@ - (instancetype)initWithFrame:(CGRect)frame [_splitViewController addSplitViewItem:_contentItem]; [self applyAppearance]; + __weak RNSidebarSplitViewComponent *weakSelf = self; _resizeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSSplitViewDidResizeSubviewsNotification object:_splitViewController.splitView queue:NSOperationQueue.mainQueue usingBlock:^(__unused NSNotification *notification) { - [self publishSplitViewLayoutAllowEstimatedReady:NO]; + [weakSelf splitViewDidResize]; }]; [self addSubview:_splitViewController.view]; @@ -480,17 +582,44 @@ - (void)updateSplitItemSizing _sidebarItem.minimumThickness = MAX(120, _sidebarMinWidth); _sidebarItem.preferredThicknessFraction = 0.26; _contentItem.minimumThickness = MAX(240, _contentMinWidth); + _listItem.minimumThickness = MAX(120, _listMinWidth); +} + +- (void)updateListItem +{ + BOOL installed = [_splitViewController.splitViewItems containsObject:_listItem]; + if (_hasList && !installed) { + [_splitViewController insertSplitViewItem:_listItem atIndex:1]; + } else if (!_hasList && installed) { + [_splitViewController removeSplitViewItem:_listItem]; + } +} + +- (CGFloat)listReserveWidth +{ + return _hasList ? _listItem.minimumThickness + _splitViewController.splitView.dividerThickness : 0; } - (CGFloat)preferredSidebarWidthForBounds:(CGRect)bounds { CGFloat dividerThickness = _splitViewController.splitView.dividerThickness; - CGFloat maxSidebarWidth = bounds.size.width - _contentMinWidth - dividerThickness; + CGFloat maxSidebarWidth = bounds.size.width - _contentItem.minimumThickness - dividerThickness - [self listReserveWidth]; CGFloat preferredSidebarWidth = _sidebarWidth > 0 ? _sidebarWidth : _sidebarMinWidth; - CGFloat sidebarWidth = MIN(MAX(_sidebarMinWidth, preferredSidebarWidth), maxSidebarWidth); + CGFloat sidebarWidth = MIN(MAX(_sidebarItem.minimumThickness, preferredSidebarWidth), maxSidebarWidth); return MAX(0, sidebarWidth); } +- (CGFloat)preferredListWidthForBounds:(CGRect)bounds sidebarExtent:(CGFloat)sidebarExtent +{ + if (!_hasList) { + return 0; + } + CGFloat dividerThickness = _splitViewController.splitView.dividerThickness; + CGFloat maxListWidth = bounds.size.width - sidebarExtent - _contentItem.minimumThickness - dividerThickness; + CGFloat preferredListWidth = _listWidth > 0 ? _listWidth : _listMinWidth; + return MAX(0, MIN(MAX(_listItem.minimumThickness, preferredListWidth), maxListWidth)); +} + - (void)updateSidebarCollapsed { if (_sidebarItem.collapsed != _sidebarCollapsed) { @@ -506,6 +635,7 @@ - (void)applyAppearance _splitViewController.splitView.appearance = appearance; _sidebarContainer.appearance = appearance; _contentContainer.appearance = appearance; + _listContainer.appearance = appearance; [_splitViewController.view setNeedsDisplay:YES]; [_splitViewController.splitView setNeedsDisplay:YES]; [_sidebarContainer setNeedsDisplay:YES]; @@ -653,6 +783,9 @@ - (void)syncReactSubviewFrames [self syncReactSubview:_contentReactView nativeBounds:contentBounds previousLayoutMetrics:&_contentReactLayoutMetrics]; + [self syncReactSubview:_listReactView + nativeBounds:_hasList ? _listContainer.bounds : CGRectZero + previousLayoutMetrics:&_listReactLayoutMetrics]; } - (void)syncReactSubview:(nullable RCTUIView *)subview @@ -692,6 +825,8 @@ - (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)sidebarWidth sidebarHeight:(CGFloat)sidebarHeight contentHeight:(CGFloat)contentHeight height:(CGFloat)height + listWidth:(CGFloat)listWidth + listX:(CGFloat)listX layoutReady:(BOOL)layoutReady { const auto eventEmitter = std::static_pointer_cast(_eventEmitter); @@ -703,7 +838,7 @@ - (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)sidebarWidth return; } - BOOL panesReady = layoutReady && _sidebarReactView && _contentReactView; + BOOL panesReady = layoutReady && _sidebarReactView && _contentReactView && (!_hasList || _listReactView); if (panesReady && self.window && !_didPublishMount) { // This also runs when an already-laid-out Fabric tree joins its window. // Keep it before metrics deduplication so the startup cover cannot linger. @@ -713,6 +848,7 @@ - (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)sidebarWidth } if (fabs(sidebarWidth - _lastSidebarWidth) < 0.5 && fabs(contentWidth - _lastContentWidth) < 0.5 && + fabs(listWidth - _lastListWidth) < 0.5 && fabs(height - _lastHeight) < 0.5 && panesReady == _lastLayoutReady) { return; @@ -720,6 +856,7 @@ - (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)sidebarWidth _lastSidebarWidth = sidebarWidth; _lastContentWidth = contentWidth; + _lastListWidth = listWidth; _lastHeight = height; _lastLayoutReady = panesReady; @@ -730,6 +867,9 @@ - (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)sidebarWidth .height = height, .isLayoutReady = static_cast(panesReady), .isVertical = true, + .listHeight = listWidth > 0 ? height : 0, + .listWidth = listWidth, + .listX = listX, .sidebarHeight = sidebarHeight, .sidebarWidth = sidebarWidth, }); @@ -746,16 +886,20 @@ - (BOOL)applyEstimatedSplitViewLayoutForBounds:(CGRect)bounds layoutReady:(BOOL) if (!_sidebarCollapsed) { sidebarWidth = [self preferredSidebarWidthForBounds:bounds]; } - CGFloat contentX = sidebarWidth > 0 ? sidebarWidth + dividerThickness : 0; + CGFloat listX = sidebarWidth > 0 ? sidebarWidth + dividerThickness : 0; + CGFloat listWidth = [self preferredListWidthForBounds:bounds sidebarExtent:listX]; + CGFloat contentX = listWidth > 0 ? listX + listWidth + dividerThickness : listX; CGFloat contentWidth = MAX(0, bounds.size.width - contentX); if (contentWidth <= 0) { contentWidth = bounds.size.width; contentX = 0; + listWidth = 0; } _splitViewController.view.frame = bounds; _splitViewController.splitView.frame = bounds; _sidebarContainer.frame = CGRectMake(0, 0, sidebarWidth, bounds.size.height); + _listContainer.frame = CGRectMake(listX, 0, listWidth, bounds.size.height); _contentContainer.frame = CGRectMake(contentX, 0, contentWidth, bounds.size.height); CGRect sidebarBounds = CGRectMake(0, 0, sidebarWidth, bounds.size.height); @@ -766,14 +910,21 @@ - (BOOL)applyEstimatedSplitViewLayoutForBounds:(CGRect)bounds layoutReady:(BOOL) [self syncReactSubview:_contentReactView nativeBounds:contentBounds previousLayoutMetrics:&_contentReactLayoutMetrics]; + [self syncReactSubview:_listReactView + nativeBounds:CGRectMake(0, 0, listWidth, bounds.size.height) + previousLayoutMetrics:&_listReactLayoutMetrics]; [self layoutContentTitlebarMaterial]; - [self emitSplitViewDidResizeWithSidebarWidth:sidebarWidth - contentWidth:contentWidth - contentX:contentX - sidebarHeight:bounds.size.height - contentHeight:bounds.size.height - height:bounds.size.height - layoutReady:layoutReady]; + if (!_layingOutSplitView) { + [self emitSplitViewDidResizeWithSidebarWidth:sidebarWidth + contentWidth:contentWidth + contentX:contentX + sidebarHeight:bounds.size.height + contentHeight:bounds.size.height + height:bounds.size.height + listWidth:listWidth + listX:_hasList ? listX : 0 + layoutReady:layoutReady]; + } return YES; } @@ -785,13 +936,16 @@ - (void)publishSplitViewLayoutAllowEstimatedReady:(BOOL)allowEstimatedReady CGFloat contentX = [_contentContainer convertRect:_contentContainer.bounds toView:self].origin.x; CGFloat sidebarHeight = _sidebarContainer.bounds.size.height; CGFloat contentHeight = _contentContainer.bounds.size.height; + CGFloat listWidth = _hasList ? _listContainer.bounds.size.width : 0; + CGFloat listX = _hasList ? [_listContainer convertRect:_listContainer.bounds toView:self].origin.x : 0; CGFloat height = MAX(sidebarHeight, contentHeight); CGRect bounds = [self currentLayoutBounds]; if (contentWidth <= 0 || height <= 0 || contentX < -0.5 || contentX + contentWidth > bounds.size.width + 0.5 || - fabs(_contentContainer.bounds.size.height - bounds.size.height) >= 0.5) { + fabs(_contentContainer.bounds.size.height - bounds.size.height) >= 0.5 || + (_hasList && listWidth <= 0)) { [self applyEstimatedSplitViewLayoutForBounds:bounds layoutReady:allowEstimatedReady]; return; } @@ -804,6 +958,8 @@ - (void)publishSplitViewLayoutAllowEstimatedReady:(BOOL)allowEstimatedReady sidebarHeight:sidebarHeight contentHeight:contentHeight height:height + listWidth:listWidth + listX:listX layoutReady:YES]; } @@ -820,25 +976,64 @@ - (CGRect)currentLayoutBounds return bounds; } +/// Seeds dividers from the preferred widths. Runs only until it succeeds once (and again when those props +/// change), so a later layout pass never snaps a divider the user dragged back to its preferred position. - (void)applyDividerPositionForBounds:(CGRect)bounds { - if (_sidebarCollapsed || bounds.size.width <= 0 || _splitViewController.splitView.subviews.count < 2) { + if (!_needsPreferredDividerPositions || bounds.size.width <= 0 || _splitViewController.splitView.subviews.count < 2) { return; } + CGFloat dividerThickness = _splitViewController.splitView.dividerThickness; + CGFloat sidebarExtent = 0; + if (!_sidebarCollapsed) { + CGFloat sidebarWidth = [self preferredSidebarWidthForBounds:bounds]; + if (sidebarWidth <= 0) { + return; + } + [_splitViewController.splitView setPosition:sidebarWidth ofDividerAtIndex:0]; + sidebarExtent = sidebarWidth + dividerThickness; + } + if (_hasList) { + if (_splitViewController.splitView.subviews.count < 3) { + return; + } + CGFloat listWidth = [self preferredListWidthForBounds:bounds sidebarExtent:sidebarExtent]; + if (listWidth <= 0) { + return; + } + [_splitViewController.splitView setPosition:sidebarExtent + listWidth ofDividerAtIndex:1]; + } + // A small startup frame can clamp a preference. Retry on later layouts until it fits, + // unless a real divider drag takes ownership in splitViewDidResize. + CGFloat desiredSidebarWidth = MAX(_sidebarItem.minimumThickness, _sidebarWidth > 0 ? _sidebarWidth : _sidebarMinWidth); + CGFloat desiredListWidth = MAX(_listItem.minimumThickness, _listWidth > 0 ? _listWidth : _listMinWidth); + _needsPreferredDividerPositions = + (!_sidebarCollapsed && [self preferredSidebarWidthForBounds:bounds] < desiredSidebarWidth - 0.5) || + (_hasList && [self preferredListWidthForBounds:bounds sidebarExtent:sidebarExtent] < desiredListWidth - 0.5); +} - CGFloat sidebarWidth = [self preferredSidebarWidthForBounds:bounds]; - if (sidebarWidth <= 0) { +- (void)splitViewDidResize +{ + if (_layingOutSplitView) { return; } - - [_splitViewController.splitView setPosition:sidebarWidth ofDividerAtIndex:0]; + if (((RNSidebarSplitViewNativeSplitView *)_splitViewController.splitView).trackingDivider) { + _needsPreferredDividerPositions = NO; + } + [self publishSplitViewLayoutAllowEstimatedReady:NO]; } - (void)layoutSplitView { + if (_layingOutSplitView) { + return; + } + _layingOutSplitView = YES; CGRect bounds = [self currentLayoutBounds]; _splitViewController.view.frame = bounds; _splitViewController.splitView.frame = bounds; + [self updateSplitItemSizing]; + [self updateListItem]; [self updateSidebarCollapsed]; if (_contentContainer.bounds.size.width <= 0 || _contentContainer.bounds.size.height <= 0 || @@ -846,9 +1041,9 @@ - (void)layoutSplitView [self applyEstimatedSplitViewLayoutForBounds:bounds layoutReady:NO]; } [self applyDividerPositionForBounds:bounds]; - [_splitViewController.splitView adjustSubviews]; [_splitViewController.view layoutSubtreeIfNeeded]; [self layoutContentTitlebarMaterial]; + _layingOutSplitView = NO; [self publishSplitViewLayoutAllowEstimatedReady:YES]; } @@ -903,13 +1098,28 @@ - (void)mountChildComponentView:(RCTUIView *)childComp }); return; } + + if (index == 2) { + [_listReactView removeFromSuperview]; + _listReactView = childComponentView; + _listReactLayoutMetrics = EmptyLayoutMetrics; + childComponentView.hidden = YES; + [_listContainer addSubview:childComponentView]; + [self syncReactSubview:_listReactView + nativeBounds:_hasList ? _listContainer.bounds : CGRectZero + previousLayoutMetrics:&_listReactLayoutMetrics]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self publishSplitViewLayoutAllowEstimatedReady:NO]; + }); + return; + } #else if (index == 0) { [_sidebarContainer addSubview:childComponentView]; return; } - if (index == 1) { + if (index == 1 || index == 2) { [_contentContainer addSubview:childComponentView]; return; } @@ -935,8 +1145,15 @@ - (void)unmountChildComponentView:(RCTUIView *)childCo [childComponentView removeFromSuperview]; return; } + + if (childComponentView == _listReactView) { + _listReactView = nil; + _listReactLayoutMetrics = EmptyLayoutMetrics; + [childComponentView removeFromSuperview]; + return; + } #else - if (index == 0 || index == 1) { + if (index == 0 || index == 1 || index == 2) { [childComponentView removeFromSuperview]; return; } @@ -954,11 +1171,16 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & if (nextAppearanceName.length == 0) { nextAppearanceName = @"system"; } + BOOL preferredWidthsChanged = + fabs(_sidebarWidth - newProps.sidebarWidth) >= 0.5 || + fabs(_listWidth - newProps.listWidth) >= 0.5 || + _hasList != newProps.hasList || + _sidebarCollapsed != newProps.sidebarCollapsed; BOOL shouldRelayout = + preferredWidthsChanged || fabs(_sidebarMinWidth - newProps.sidebarMinWidth) >= 0.5 || - fabs(_sidebarWidth - newProps.sidebarWidth) >= 0.5 || fabs(_contentMinWidth - newProps.contentMinWidth) >= 0.5 || - _sidebarCollapsed != newProps.sidebarCollapsed || + fabs(_listMinWidth - newProps.listMinWidth) >= 0.5 || fabs(_contentTitlebarHeight - newProps.contentTitlebarHeight) >= 0.5; NSString *nextContentTitlebarMaterialName = [NSString stringWithUTF8String:newProps.contentTitlebarMaterial.c_str()]; if (nextContentTitlebarMaterialName.length == 0) { @@ -988,7 +1210,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _sidebarMinWidth = newProps.sidebarMinWidth; _sidebarWidth = newProps.sidebarWidth; _contentMinWidth = newProps.contentMinWidth; + _listMinWidth = newProps.listMinWidth; + _listWidth = newProps.listWidth; + _hasList = newProps.hasList; _sidebarCollapsed = newProps.sidebarCollapsed; + if (preferredWidthsChanged) { + _needsPreferredDividerPositions = YES; + } _contentTitlebarHeight = newProps.contentTitlebarHeight; _contentTitlebarMaterialName = nextContentTitlebarMaterialName; _contentTitlebarOverlayColorValue = nextContentTitlebarOverlayColorValue; @@ -1005,8 +1233,6 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _appearanceName = nextAppearanceName; [self applyAppearance]; } - [self updateSidebarCollapsed]; - [self updateSplitItemSizing]; #endif [super updateProps:props oldProps:oldProps]; @@ -1015,6 +1241,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & if (shouldRelayout || shouldRecreateContentTitlebarMaterial || shouldRecreateSidebarTitlebarMaterial) { _lastSidebarWidth = -1; _lastContentWidth = -1; + _lastListWidth = -1; _lastHeight = -1; _lastLayoutReady = NO; [self layoutSplitView]; @@ -1086,17 +1313,26 @@ - (void)prepareForRecycle #if TARGET_OS_OSX [_sidebarReactView removeFromSuperview]; [_contentReactView removeFromSuperview]; + [_listReactView removeFromSuperview]; _sidebarReactView = nil; _contentReactView = nil; + _listReactView = nil; _currentLayoutMetrics = EmptyLayoutMetrics; _sidebarReactLayoutMetrics = EmptyLayoutMetrics; _contentReactLayoutMetrics = EmptyLayoutMetrics; + _listReactLayoutMetrics = EmptyLayoutMetrics; _sidebarMinWidth = 180; _sidebarWidth = 0; _contentMinWidth = 320; + _listMinWidth = 240; + _listWidth = 0; + _hasList = NO; _sidebarCollapsed = NO; + _needsPreferredDividerPositions = YES; + _layingOutSplitView = NO; _lastSidebarWidth = -1; _lastContentWidth = -1; + _lastListWidth = -1; _lastHeight = -1; _lastLayoutReady = NO; _appearanceName = @"system"; @@ -1110,6 +1346,7 @@ - (void)prepareForRecycle [self removeContentTitlebarMaterial]; [self removeSidebarTitlebarMaterial]; [self applyAppearance]; + [self updateListItem]; [self updateSidebarCollapsed]; [self updateSplitItemSizing]; #else diff --git a/packages/ui/src/appkit-split-view/SidebarSplitViewNativeComponent.ts b/packages/ui/src/appkit-split-view/SidebarSplitViewNativeComponent.ts index 7d5a022f..68a115ed 100644 --- a/packages/ui/src/appkit-split-view/SidebarSplitViewNativeComponent.ts +++ b/packages/ui/src/appkit-split-view/SidebarSplitViewNativeComponent.ts @@ -9,6 +9,9 @@ export type SidebarSplitViewResizeEvent = Readonly<{ height: Double; isLayoutReady: boolean; isVertical: boolean; + listHeight: Double; + listWidth: Double; + listX: Double; sidebarHeight: Double; sidebarWidth: Double; }>; @@ -20,6 +23,9 @@ export interface NativeProps extends ViewProps { contentTitlebarOverlayColor?: string; contentTitlebarOverlayOpacity?: Double; contentMinWidth?: Double; + hasList?: boolean; + listMinWidth?: Double; + listWidth?: Double; onSplitViewDidResize?: DirectEventHandler; sidebarCollapsed?: boolean; sidebarMinWidth?: Double; diff --git a/packages/ui/src/appkit-split-view/index.tsx b/packages/ui/src/appkit-split-view/index.tsx index 9aa21689..9aa1c1c3 100644 --- a/packages/ui/src/appkit-split-view/index.tsx +++ b/packages/ui/src/appkit-split-view/index.tsx @@ -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 }; @@ -16,11 +16,15 @@ export interface SplitViewTitleBarOptions { export interface SidebarSplitViewProps extends Omit { 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; @@ -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}`); @@ -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"]); @@ -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(() => initialPaneMetrics ? { ...initialPaneMetrics } : undefined); - function resize(event: NativeSyntheticEvent) { + function resize(event: NativeSyntheticEvent) { 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 {sidebarCollapsed ? null : sidebar}{content}; - return {sidebarCollapsed ? null : sidebar}{list}{content}; + return {sidebar} - {content} + {content} + {hasList ? {list} : null} ; } diff --git a/tests/split-view-layout-native.test.ts b/tests/split-view-layout-native.test.ts new file mode 100644 index 00000000..0b95c00e --- /dev/null +++ b/tests/split-view-layout-native.test.ts @@ -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); diff --git a/tests/split-view-layout.native.mm b/tests/split-view-layout.native.mm new file mode 100644 index 00000000..31d578ba --- /dev/null +++ b/tests/split-view-layout.native.mm @@ -0,0 +1,172 @@ +#import +#import +#include +namespace facebook::react { +using Float = double; +struct Point { Float x, y; }; +struct Size { Float width, height; }; +struct Rect { Point origin; Size size; }; +} +using facebook::react::Float; +struct LayoutMetrics { + facebook::react::Rect frame{}; + struct {} contentInsets, borderWidth, overflowInset; + bool operator==(const LayoutMetrics &other) const { return !(*this != other); } + bool operator!=(const LayoutMetrics &other) const { return frame.size.width != other.frame.size.width || frame.size.height != other.frame.size.height; } +}; +const LayoutMetrics EmptyLayoutMetrics{}; +const NSUInteger RNComponentViewUpdateMaskLayoutMetrics = 1; +@protocol RCTComponentViewProtocol +- (void)updateLayoutMetrics:(const LayoutMetrics &)metrics oldLayoutMetrics:(const LayoutMetrics &)oldMetrics; +- (void)finalizeUpdates:(NSUInteger)mask; +@end +#define RCTUIView NSView +@interface FakeComponentView : NSView +- (void)mountChildComponentView:(NSView *)child index:(NSInteger)index; +- (void)unmountChildComponentView:(NSView *)child index:(NSInteger)index; +@end +@implementation FakeComponentView +- (void)mountChildComponentView:(NSView *)child index:(NSInteger)index { [self addSubview:child]; } +- (void)unmountChildComponentView:(NSView *)child index:(NSInteger)index { [child removeFromSuperview]; } +@end +@interface FakeReactView : NSView +@end +@implementation FakeReactView +- (void)updateLayoutMetrics:(const LayoutMetrics &)metrics oldLayoutMetrics:(const LayoutMetrics &)oldMetrics {} +- (void)finalizeUpdates:(NSUInteger)mask {} +@end +// Actual NSSplitView subclasses and selected layout methods are inserted from the reviewed source. +// Fabric synchronizing/event-emitting boundaries are replaced with frame/event recording below. +// ACTUAL_CLASSES +@interface TrackingSplitView : RNSidebarSplitViewNativeSplitView +@property BOOL testTracking; +@end +@implementation TrackingSplitView +- (BOOL)trackingDivider { return self.testTracking; } +@end +@interface Probe : FakeComponentView { +@public + NSSplitViewController *_splitViewController; + NSViewController *_sidebarViewController, *_contentViewController, *_listViewController; + NSSplitViewItem *_sidebarItem, *_contentItem, *_listItem; + NSView *_sidebarContainer, *_contentContainer, *_listContainer; + NSView *_contentReactView; + CGFloat _sidebarMinWidth, _sidebarWidth, _contentMinWidth, _listMinWidth, _listWidth; + BOOL _hasList, _sidebarCollapsed, _needsPreferredDividerPositions, _layingOutSplitView; + LayoutMetrics _currentLayoutMetrics, _sidebarReactLayoutMetrics, _contentReactLayoutMetrics, _listReactLayoutMetrics; + NSView *_sidebarReactView, *_listReactView; + NSMutableArray *_events; + id _resizeObserver; +} +- (void)layoutSplitView; +- (BOOL)applyEstimatedSplitViewLayoutForBounds:(CGRect)bounds layoutReady:(BOOL)ready; +@end +@implementation Probe +- (instancetype)initWithFrame:(NSRect)frame { + if ((self = [super initWithFrame:frame])) { + _sidebarMinWidth = 180; _sidebarWidth = 200; _contentMinWidth = 320; + _listMinWidth = 240; _listWidth = 300; _needsPreferredDividerPositions = YES; + _events = [NSMutableArray new]; + _sidebarContainer = [NSView new]; _contentContainer = [NSView new]; _listContainer = [NSView new]; + _sidebarViewController = [NSViewController new]; _contentViewController = [NSViewController new]; _listViewController = [NSViewController new]; + _sidebarViewController.view = _sidebarContainer; _contentViewController.view = _contentContainer; _listViewController.view = _listContainer; + _sidebarItem = [NSSplitViewItem sidebarWithViewController:_sidebarViewController]; + _contentItem = [NSSplitViewItem splitViewItemWithViewController:_contentViewController]; + _listItem = [NSSplitViewItem contentListWithViewController:_listViewController]; + _sidebarItem.canCollapse = YES; _listItem.holdingPriority = NSLayoutPriorityDefaultLow + 1; + _splitViewController = [NSSplitViewController new]; _splitViewController.splitView = [TrackingSplitView new]; + _splitViewController.splitView.vertical = YES; _splitViewController.splitView.dividerStyle = NSSplitViewDividerStyleThin; + [_splitViewController addSplitViewItem:_sidebarItem]; [_splitViewController addSplitViewItem:_contentItem]; + [self addSubview:_splitViewController.view]; [self updateSplitItemSizing]; + __weak Probe *weakSelf = self; + _resizeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSSplitViewDidResizeSubviewsNotification object:_splitViewController.splitView queue:nil usingBlock:^(NSNotification *note) { [weakSelf splitViewDidResize]; }]; + } + return self; +} +- (void)layoutContentTitlebarMaterial {} +- (void)emitSplitViewDidResizeWithSidebarWidth:(CGFloat)s contentWidth:(CGFloat)c contentX:(CGFloat)x sidebarHeight:(CGFloat)sh contentHeight:(CGFloat)ch height:(CGFloat)h listWidth:(CGFloat)l listX:(CGFloat)lx layoutReady:(BOOL)r { + [_events addObject:@{@"sidebar":@(s), @"content":@(c), @"contentX":@(x), @"list":@(l), @"listX":@(lx), @"ready":@(r)}]; +} +- (void)dealloc { if (_resizeObserver) [[NSNotificationCenter defaultCenter] removeObserver:_resizeObserver]; } +// ACTUAL_METHODS +@end +static void require(BOOL value, NSString *message) { if (!value) { fprintf(stderr, "%s\n", message.UTF8String); exit(1); } } +int main(int argc, char **argv) { @autoreleasepool { + [NSApplication sharedApplication]; + NSString *mode = argc > 1 ? @(argv[1]) : @"all"; + Probe *p = [[Probe alloc] initWithFrame:NSMakeRect(0,0,1200,500)]; + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0,0,1200,500) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + window.contentView = p; + if ([mode isEqual:@"anchor"]) { + p->_contentContainer.frame = NSMakeRect(201,0,999,500); + p->_contentReactView = [[FakeReactView alloc] initWithFrame:NSMakeRect(0,0,999,500)]; + [p->_contentContainer addSubview:p->_contentReactView]; + NSView *child = [[NSView alloc] initWithFrame:NSMakeRect(0,0,100,50)]; [p->_contentReactView addSubview:child]; + ((TrackingSplitView *)p->_splitViewController.splitView).testTracking = YES; + [p syncReactSubviewFrames]; + printf("Narrow content bounds offset = %.1f\n", p->_contentReactView.bounds.origin.x); + require(fabs(p->_contentReactView.bounds.origin.x) < 0.5, @"Narrow content permanently shifted after divider drag"); + } else if ([mode isEqual:@"metrics"]) { + [p applyEstimatedSplitViewLayoutForBounds:p.bounds layoutReady:NO]; + printf("No-list provisional listX = %.1f\n", [p->_events.lastObject[@"listX"] doubleValue]); + require([p->_events.lastObject[@"listX"] doubleValue] == 0, @"Absent list reports nonzero listX"); + } else if ([mode isEqual:@"clamp"]) { + [window setContentSize:NSMakeSize(550,500)]; p.frame = NSMakeRect(0,0,550,500); p->_sidebarWidth = 300; + [p layoutSplitView]; + printf("Small initial sidebar = %.1f pending=%d\n", p->_sidebarContainer.bounds.size.width,p->_needsPreferredDividerPositions); + [window setContentSize:NSMakeSize(1200,500)]; p.frame = NSMakeRect(0,0,1200,500); [p layoutSplitView]; + printf("Restored sidebar = %.1f\n",p->_sidebarContainer.bounds.size.width); + require(fabs(NSMaxX([p->_sidebarContainer convertRect:p->_sidebarContainer.bounds toView:p->_splitViewController.splitView])-300)<1,@"Preferred sidebar width never recovered after clamped startup"); + } else if ([mode isEqual:@"events"]) { + p->_hasList = YES; [p layoutSplitView]; + printf("Owned initial layout events = %lu\n", (unsigned long)p->_events.count); + require(p->_events.count == 1, @"Owned initial layout published transient metrics"); + } else if ([mode isEqual:@"ownership"]) { + [window setContentSize:NSMakeSize(550,500)]; p.frame = NSMakeRect(0,0,550,500); p->_sidebarWidth = 300; [p layoutSplitView]; + ((TrackingSplitView *)p->_splitViewController.splitView).testTracking = YES; + [p->_splitViewController.splitView setPosition:190 ofDividerAtIndex:0]; + [p splitViewDidResize]; + ((TrackingSplitView *)p->_splitViewController.splitView).testTracking = NO; + CGFloat dragged = p->_sidebarContainer.bounds.size.width; + require(!p->_needsPreferredDividerPositions, @"Drag did not take ownership from pending preferred width"); + [window setContentSize:NSMakeSize(1200,500)]; p.frame = NSMakeRect(0,0,1200,500); [p layoutSplitView]; + require(fabs(p->_sidebarContainer.bounds.size.width-dragged)<1,@"Growing window overrode user drag"); + } else if ([mode isEqual:@"mount"]) { + p->_hasList = YES; [p layoutSplitView]; + FakeReactView *sidebar = [FakeReactView new], *content = [FakeReactView new], *list = [FakeReactView new]; + [p mountChildComponentView:sidebar index:0]; + [p mountChildComponentView:content index:1]; + [p mountChildComponentView:list index:2]; + require(sidebar.superview == p->_sidebarContainer && content.superview == p->_contentContainer && list.superview == p->_listContainer,@"Native mount indices routed to wrong panes"); + require(!sidebar.hidden && !content.hidden && !list.hidden,@"Mounted panes did not become visible"); + [p unmountChildComponentView:list index:2]; + require(!p->_listReactView && !list.superview && content.superview==p->_contentContainer,@"Removing list disturbed content mount"); + [p mountChildComponentView:list index:2]; + require(list.superview==p->_listContainer && content.superview==p->_contentContainer,@"Restoring list disturbed content mount"); + } else if ([mode isEqual:@"toggle"]) { + p->_hasList = YES; [p layoutSplitView]; + require(p->_splitViewController.splitViewItems.count == 3,@"List was not installed"); + p->_sidebarCollapsed = YES; p->_needsPreferredDividerPositions = YES; [p layoutSplitView]; + require(p->_sidebarItem.collapsed && p->_listContainer.bounds.size.width>0,@"Collapsed sidebar lost list pane"); + p->_hasList = NO; p->_needsPreferredDividerPositions = YES; [p layoutSplitView]; + require(p->_splitViewController.splitViewItems.count == 2,@"List was not removed"); + require([p->_events.lastObject[@"list"] doubleValue]==0 && [p->_events.lastObject[@"listX"] doubleValue]==0,@"Removed list retained metrics"); + p->_hasList = YES; p->_sidebarCollapsed = NO; p->_needsPreferredDividerPositions = YES; [p layoutSplitView]; + require(p->_splitViewController.splitViewItems.count==3 && !p->_sidebarItem.collapsed,@"List/sidebar did not restore"); + } else { + p->_hasList = YES; [p layoutSplitView]; + ((TrackingSplitView *)p->_splitViewController.splitView).testTracking = YES; + [p->_splitViewController.splitView setPosition:260 ofDividerAtIndex:0]; + [p->_splitViewController.splitView setPosition:621 ofDividerAtIndex:1]; + ((TrackingSplitView *)p->_splitViewController.splitView).testTracking = NO; + CGFloat sidebar = p->_sidebarContainer.bounds.size.width, list = p->_listContainer.bounds.size.width; + printf("Dragged sidebar/list %.1f/%.1f\n",sidebar,list); + p.frame = NSMakeRect(0,0,1200,600); [p layoutSplitView]; + printf("Height resize sidebar/list %.1f/%.1f\n",p->_sidebarContainer.bounds.size.width,p->_listContainer.bounds.size.width); + require(fabs(sidebar-p->_sidebarContainer.bounds.size.width)<1 && fabs(list-p->_listContainer.bounds.size.width)<1,@"Height resize reset dragged pane widths"); + p.frame = NSMakeRect(0,0,1400,600); [p layoutSplitView]; + printf("Width resize sidebar/list %.1f/%.1f\n",p->_sidebarContainer.bounds.size.width,p->_listContainer.bounds.size.width); + require(fabs(sidebar-p->_sidebarContainer.bounds.size.width)<1 && fabs(list-p->_listContainer.bounds.size.width)<1,@"Width resize reset dragged pane widths"); + } + puts("Native split-view probe passed"); +} return 0; } diff --git a/tests/ui-specialized.test.ts b/tests/ui-specialized.test.ts index b9e195d9..670451ba 100644 --- a/tests/ui-specialized.test.ts +++ b/tests/ui-specialized.test.ts @@ -47,13 +47,63 @@ test("split views preserve zero metrics and expose explicit readiness without na const onResize = vi.fn(), onError = vi.fn(); await mount(React.createElement(SidebarSplitView, { sidebar: "Sidebar", content: "Content", onResize, onError, titleBar: { content: { height: 50, overlay: { color: "#ffffff" } } } })); const native = rendered.root.findByType("SidebarSplitView"); expect(native.props.contentTitlebarOverlayOpacity).toBe(1); expect(native.props.contentTitlebarHeight).toBe(50); - const data = { contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200, contentX: 201, height: 100, isLayoutReady: false, isVertical: true }; + const data = { contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200, listHeight: 0, listWidth: 0, listX: 0, contentX: 201, height: 100, isLayoutReady: false, isVertical: true }; await act(async () => native.props.onSplitViewDidResize({ nativeEvent: data })); - expect(onResize.mock.calls[0][0]).toEqual({ contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200, contentX: 201, height: 100, phase: "provisional" }); + expect(native.props.hasList).toBe(false); + expect(onResize.mock.calls[0][0]).toEqual({ contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200, listHeight: 0, listWidth: 0, listX: 0, contentX: 201, height: 100, phase: "provisional" }); await act(async () => native.props.onSplitViewDidResize({ nativeEvent: { ...data, sidebarWidth: 0, sidebarHeight: 0, isLayoutReady: true } })); const panes = rendered.root.findAllByType("View"); expect(panes[0].props.style.width).toBe(0); expect(panes[0].props.style.height).toBe(0); expect(onResize.mock.calls[1][0].phase).toBe("ready"); native.props.onSplitViewDidResize({ nativeEvent: { ...data, contentWidth: NaN } }); expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: "E_INVALID_DATA" })); expect(onResize).toHaveBeenCalledTimes(2); }); +test("split views host an optional native list column after the sidebar and content panes", async () => { + const onResize = vi.fn(); + await mount(React.createElement(SidebarSplitView, { sidebar: "Sidebar", list: "List", content: "Content", listWidth: 360, listMinWidth: 280, onResize })); + const native = rendered.root.findByType("SidebarSplitView"); + expect(native.props).toMatchObject({ hasList: true, listWidth: 360, listMinWidth: 280 }); + // Child order is the native mount contract: 0 sidebar, 1 content, 2 list. + let panes = native.findAllByType("View").filter((view: { parent: unknown }) => view.parent === native); + expect(panes.map((pane: { props: { children: unknown } }) => pane.props.children)).toEqual(["Sidebar", "Content", "List"]); + expect(panes[2].props.style.width).toBe(360); + const data = { contentHeight: 100, contentWidth: 400, sidebarHeight: 100, sidebarWidth: 200, listHeight: 100, listWidth: 300, listX: 201, contentX: 502, height: 100, isLayoutReady: true, isVertical: true }; + await act(async () => native.props.onSplitViewDidResize({ nativeEvent: data })); + panes = native.findAllByType("View").filter((view: { parent: unknown }) => view.parent === native); + expect(panes[2].props.style).toMatchObject({ width: 300, height: 100 }); + expect(onResize.mock.calls[0][0]).toMatchObject({ listWidth: 300, listX: 201, contentX: 502, phase: "ready" }); +}); +test.each([undefined, null, false, true, ""])("split views omit non-rendering conditional list values (%s)", async list => { + await mount(React.createElement(SidebarSplitView, { sidebar: "Sidebar", content: "Content", list })); + const native = rendered.root.findByType("SidebarSplitView"); + expect(native.props.hasList).toBe(false); + expect(native.children).toHaveLength(2); +}); +test("split views retain content across list toggles and render a numeric zero list", async () => { + const mounted = vi.fn(), unmounted = vi.fn(); + function Content() { React.useEffect(() => { mounted(); return unmounted; }, []); return React.createElement("ContentProbe"); } + const content = React.createElement(Content); + for (const list of [undefined, "List", null, 0]) { + const element = React.createElement(SidebarSplitView, { sidebar: "Sidebar", content, list }); + if (!rendered) await mount(element); else await act(async () => rendered.update(element)); + const native = rendered.root.findByType("SidebarSplitView"); + expect(native.props.hasList).toBe(list === "List" || list === 0); + expect(mounted).toHaveBeenCalledTimes(1); expect(unmounted).not.toHaveBeenCalled(); + } +}); +test("split views require complete native list metrics but allow older initial hints", async () => { + const onResize = vi.fn(), onError = vi.fn(); + await mount(React.createElement(SidebarSplitView, { sidebar: "Sidebar", content: "Content", onResize, onError, + initialPaneMetrics: { contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200 } })); + const native = rendered.root.findByType("SidebarSplitView"); + const data = { contentHeight: 100, contentWidth: 500, sidebarHeight: 100, sidebarWidth: 200, listHeight: 0, listWidth: 0, listX: 0, contentX: 201, height: 100, isLayoutReady: true }; + for (const field of ["listWidth", "listHeight"] as const) { + for (const invalid of [undefined, NaN, -1]) { + native.props.onSplitViewDidResize({ nativeEvent: { ...data, [field]: invalid } }); + expect(onError).toHaveBeenLastCalledWith(expect.objectContaining({ code: "E_INVALID_DATA" })); + } + } + expect(onResize).not.toHaveBeenCalled(); + await act(async () => native.props.onSplitViewDidResize({ nativeEvent: data })); + expect(onResize).toHaveBeenCalledWith(expect.objectContaining({ listWidth: 0, listHeight: 0, phase: "ready" })); +}); test("sidebar data selection can be cleared and unknown/disabled choices reject", async () => { const onSelectionChange = vi.fn(), onError = vi.fn(); await mount(React.createElement(Sidebar, { items: [{ id: "a", label: "A" }, { id: "b", label: "B", selectable: false }], selectedId: "a", onSelectionChange, onError }));