UiStack: per-child layout and optional ownership of a focus scope - #3
Conversation
There was a problem hiding this comment.
🟡 Not ready to approve
Several views now allow scopeId to be omitted, but their types and focusDefault() behavior don’t consistently handle undefined, which can cause type-safety issues and unclear runtime focus behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR enhances UiStack to support per-child layout options and (optionally) own a single focus scope that composes navigation across its children, enabling “ragged grid” navigation for independently-sized rows without app-layer wiring.
Changes:
UiStackchildren become records (UiStackChild) with per-child alignment and spacing, plus newjustifybehavior.UiStackcan own a focus scope (scopeId) and provide composed directional navigation;UiRow,UiGrid, andUiButtonimplement the composable focus interface to participate.- Adds README guidance and new sample screens demonstrating stacked layout + composed focus, and bumps
ui-coredependency.
File summaries
| File | Description |
|---|---|
| stack.ts | Adds per-child layout options, justify, and optional focus-scope ownership with composed navigation. |
| row.ts | Makes scopeId optional for stack-owned scopes and adds composable focus support helpers. |
| grid.ts | Makes scopeId optional for stack-owned scopes and adds composable focus support helpers. |
| button.ts | Adds composable focus support and split render passes (controls vs focus overlay). |
| samples.ts | Adds sample screens demonstrating stack-owned scope navigation and layout. |
| README.md | Documents how to stack rows/grids into a single focus scope and use per-child layout. |
| pxt.json | Bumps ui-core dependency to v0.0.8. |
Review details
Suppressed comments (6)
stack.ts:142
- The
scopeIdgetter is documented as possiblyundefinedfor layout-only stacks, but its return type isUiFocusScopeId(non-optional). This should beUiFocusScopeId | undefinedto match behavior andUiStackOptions.scopeId?:.
public get scopeId(): UiFocusScopeId {
return this.scopeId_
}
stack.ts:460
focusChildreturnsundefinedwhen a child isn't composable-focus aware, but the return type is declared asUiComposableFocusView<any>(non-optional). This should be an optional return type to accurately represent the function contract.
private focusChild(index: number): UiComposableFocusView<any> {
row.ts:124
UiRowOptions.scopeIdis optional, but thescopeIdgetter is typed as non-optional. ReturningUiFocusScopeId | undefinedbetter reflects actual state before a parent assigns a scope viasetScopeId().
public get scopeId(): UiFocusScopeId {
return this.scopeId_
}
/**
row.ts:293
- Now that
scopeIdcan be omitted (for stack-owned scopes),focusDefault()should handlescopeId_ === undefinedthe same wayregisterFocusTargets()does; otherwise callers can end up callingsetActiveScope(undefined)with unclear downstream behavior.
public registerNavigation(controller: UiFocusInputController): void {
if (this.scopeId_ === undefined) return
controller.setNavigation(this.scopeId_, {
kind: "row",
targets: this.navigationTargets(),
grid.ts:102
UiGridOptions.scopeIdis optional, but thescopeIdgetter is typed as non-optional. ReturningUiFocusScopeId | undefinedbetter reflects actual state before a parent assigns a scope viasetScopeId().
public get scopeId(): UiFocusScopeId {
return this.scopeId_
}
/**
grid.ts:229
- Now that
scopeIdcan be omitted (for stack-owned scopes),focusDefault()should handlescopeId_ === undefinedthe same wayregisterFocusTargets()does; otherwise callers can end up callingsetActiveScope(undefined)with unclear downstream behavior.
public registerNavigation(controller: UiFocusInputController): void {
if (this.scopeId_ === undefined) return
controller.setNavigation(this.scopeId_, {
kind: "raggedGrid",
rows: this.navigationRows(),
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟡 Not ready to approve
UiStack currently short-circuits focus registration/navigation when scopeId is omitted, which can silently disable focus for focusable children used under a layout-only stack.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
stack.ts:221
arrange()measures every child twice whenjustifyis left at its default of"start"(once viacontentMainSize()and again inside the placement loop). Skipping thecontentMainSize()pass when no justification is applied avoids a full extra measurement pass in the common case.
const available = row ? this.finalRect.width : this.finalRect.height
const content = this.contentMainSize()
const slack = Math.max(0, available - content)
let pos = (row ? this.finalRect.x : this.finalRect.y) +
this.justifyOffset(slack)
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟡 Not ready to approve
There are API/type-safety issues that can break downstream consumers (e.g., required children typing and undefined scopeId_ use in preferred-target resolution) that should be corrected before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (4)
stack.ts:227
arrange()measures each child twice: once insidecontentMainSize()(called earlier in this method) and again in the per-child loop. Ifmeasure()is non-trivial (common for composite views), this doubles layout work every time the stack is arranged. Consider caching the measured sizes from the first pass (e.g., into reusable arrays) and reusing them during arrangement.
const child = this.children_[i]
const alignment = child.alignment || this.alignment_
child.view.measure(this.childConstraints_, this.childSize_)
const childWidth = this.childSize_.preferredWidth
const childHeight = this.childSize_.preferredHeight
stack.ts:53
UiStackOptions.childrenis declared as required, butUiStackstill treats it as optional (this.setChildren(options.children)andchildren || []). This is an API-breaking type change for callers that previously relied on the default empty children list; the type should reflect the runtime behavior by makingchildrenoptional.
children: UiStackChild[]
row.ts:306
- Because
scopeId_can now beundefined,_uiControls.preferredControlId(scopeId: string, ...)can be called with an undefined scope viaresolvePreferredTargetId()(immediately below this method). This can fail type-checking understrictNullChecksand can produce invalid target ids at runtime if called before a parent assigns a scope. GuardresolvePreferredTargetId()to returnundefinedwhenscopeId_is missing.
public focusDefault(focus: UiFocusState): UiFocusSetResult {
if (this.scopeId_ === undefined)
return { kind: "rejected", reason: "missingScope" }
return focus.setActiveScope(this.scopeId_)
}
grid.ts:242
- Because
scopeId_can now beundefined,_uiControls.preferredControlId(scopeId: string, ...)can be called with an undefined scope viaresolvePreferredTargetId()(immediately below this method). This can fail type-checking understrictNullChecksand can produce invalid target ids at runtime if called before a parent assigns a scope. GuardresolvePreferredTargetId()to returnundefinedwhenscopeId_is missing.
public focusDefault(focus: UiFocusState): UiFocusSetResult {
if (this.scopeId_ === undefined)
return { kind: "rejected", reason: "missingScope" }
return focus.setActiveScope(this.scopeId_)
}
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟡 Not ready to approve
There are correctness/perf gaps in the new stack composition path (runtime guard robustness and avoidable extra measurement work) plus missing automated coverage for the new composed-focus behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (3)
stack.ts:499
focusChild()only checks fornavigationRowsandregisterFocusTargets, butUiStacklater callsresolvePreferredTargetId()on the returned child (viaresolvePreferredTargetId()and also constructs stack scope options). If a view partially implements the composable focus surface, this will throw at runtime. Consider tightening the guard to ensure the full required surface is present before treating a child as focus-composable.
private focusChild(
index: number,
): UiComposableFocusView<any> | undefined {
const child = <any>this.children_[index].view
if (!child.navigationRows || !child.registerFocusTargets)
return undefined
return <UiComposableFocusView<any>>child
stack.ts:222
arrange()always callscontentMainSize(), which measures every child once, and then measures every child again inside the arrangement loop. For the common/default case (justify: "start"), the slack/offset/spacing are always zero, so the extra full measurement pass can be skipped to avoid an avoidable per-frame cost.
const row = this.orientation_ == "row"
const available = row ? this.finalRect.width : this.finalRect.height
const content = this.contentMainSize()
const slack = Math.max(0, available - content)
let pos = (row ? this.finalRect.x : this.finalRect.y) +
this.justifyOffset(slack)
const spacing = this.justifySpacing(slack)
stack.ts:296
UiStacknow owns an optional focus scope and composes child focus surfaces (target registration + ragged-grid navigation + wrapping/verticalStrategy), but there are no automated tests exercising this new behavior. Adding a smoke/unit test that builds a stack with a mix ofUiRow/UiGrid/UiButtonand verifies cross-child directional movement (including hidden/disabled targets) would help prevent regressions.
public registerFocusTargets(
focus: UiFocusState,
scopeOptions?: UiFocusScopeOptions,
): void {
if (this.scopeId_ === undefined) {
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟡 Not ready to approve
UiStack.focusChild() currently silently drops focusable-but-non-composable children in a scoped stack (contradicting its own comment), which can make such children unreachable without an explicit failure mode.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
stack.ts:516
- In a scope-owning stack, children are only registered/navigated when
focusChild()returns them. As written,focusChild()silently returnsundefinedfor any focusable child that lacks one of the required composable members, which means that child won’t register focus targets at all (so it becomes unreachable) even though the comment says it’s “left to itself rather than half driven”. Consider failing fast with an assertion when a child looks focusable (hasregisterFocusTargets) but doesn’t implement the fullUiComposableFocusViewsurface, so this doesn’t degrade silently.
const child = <any>this.children_[index].view
// The whole composable surface is required, since this stack calls
// every part of it. A view offering only some of it is left to
// itself rather than half driven.
if (
!child.navigationRows ||
!child.registerFocusTargets ||
!child.resolvePreferredTargetId ||
!child.setScopeId
)
return undefined
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟡 Human review recommended
It changes core layout and focus-scope/navigation behavior across multiple fundamental controls (and bumps ui-core), so it warrants final human validation in a real app flow beyond the added smoke tests.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Problem: A screen that wants several rows of controls, each centered on its own width with headers between them, could not express that.
UiGridplaces every row from the same left edge with uniform widths, and rows added to a screen separately each own a focus scope, making navigation between them an app-layer, hand-wired concern.Fix:
UiStackgains new functionality:scopeId, the stack owns one focus scope for its children and answers arrow keys for all of them, so a column of independently sized rows navigates as a single ragged grid.UiRow,UiGridandUiButtonimplement the new composable view interface from ui-core, which is what lets a stack drive them. Their ownscopeIdbecomes optional, so a screen names the scope once on the stack rather than once per row.Also adds a README section and two sample screens illustrating the feature.