Feat: Modal/Alert as focus sections — replace focusManager.clear() with section activation

ModalPresentationModifier and AlertPresentationModifier now register
dedicated focus sections (__modal__/__alert__) and activate them instead
of clearing the entire FocusManager. Modal buttons register in the modal
section. When the modal closes, the section is not re-registered and
endRenderPass() falls back to the previous active section automatically.
This commit is contained in:
phranck
2026-02-03 16:59:57 +01:00
parent b078661e7e
commit 93958e0308
2 changed files with 35 additions and 8 deletions
@@ -57,6 +57,9 @@ public struct AlertPresentationModifier<Content: View, Actions: View, Message: V
// MARK: - Renderable
extension AlertPresentationModifier: Renderable {
/// A stable section ID for alert focus sections.
private static var alertSectionID: String { "__alert__" }
func renderToBuffer(context: RenderContext) -> FrameBuffer {
// If not presented, just return base content
guard isPresented.wrappedValue else {
@@ -82,6 +85,8 @@ extension AlertPresentationModifier: Renderable {
actions: { actions }
)
let focusManager = context.environment.focusManager
// Render dimmed base with an isolated context.
// The base content's buttons and key handlers register into a
// throwaway FocusManager and KeyEventDispatcher so they don't
@@ -90,11 +95,19 @@ extension AlertPresentationModifier: Renderable {
let isolatedContext = context.isolatedForBackground()
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext)
// Clear the real focus manager so the alert's buttons become
// the only registered focusables (auto-focus picks the first one).
context.environment.focusManager.clear()
// Register an alert focus section and activate it.
// The alert section becomes the active section, so Tab/arrows
// only navigate within the alert's focusable elements (buttons).
let sectionID = Self.alertSectionID
focusManager.registerSection(id: sectionID)
focusManager.activateSection(id: sectionID)
let alertBuffer = TUIkit.renderToBuffer(alert, context: context)
// Set the alert section in the context so child focusables
// (buttons in the alert) register in the alert section.
var alertContext = context
alertContext.activeFocusSectionID = sectionID
let alertBuffer = TUIkit.renderToBuffer(alert, context: alertContext)
guard !dimmedBuffer.isEmpty else {
return alertBuffer
@@ -42,12 +42,17 @@ public struct ModalPresentationModifier<Content: View, Modal: View>: View {
// MARK: - Renderable
extension ModalPresentationModifier: Renderable {
/// A stable section ID for modal focus sections.
private static var modalSectionID: String { "__modal__" }
func renderToBuffer(context: RenderContext) -> FrameBuffer {
// If not presented, just return base content
guard isPresented.wrappedValue else {
return TUIkit.renderToBuffer(content, context: context)
}
let focusManager = context.environment.focusManager
// Render dimmed base with an isolated context.
// The base content's buttons and key handlers register into a
// throwaway FocusManager and KeyEventDispatcher so they don't
@@ -56,11 +61,20 @@ extension ModalPresentationModifier: Renderable {
let isolatedContext = context.isolatedForBackground()
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext)
// Clear the real focus manager so the modal's buttons become
// the only registered focusables (auto-focus picks the first one).
context.environment.focusManager.clear()
// Register a modal focus section and activate it.
// This replaces the previous focusManager.clear() approach.
// The modal section becomes the active section, so Tab/arrows
// only navigate within the modal's focusable elements.
let sectionID = Self.modalSectionID
focusManager.registerSection(id: sectionID)
focusManager.activateSection(id: sectionID)
let modalBuffer = TUIkit.renderToBuffer(modal, context: context)
// Set the modal section in the context so child focusables
// (buttons in the modal) register in the modal section.
var modalContext = context
modalContext.activeFocusSectionID = sectionID
let modalBuffer = TUIkit.renderToBuffer(modal, context: modalContext)
guard !dimmedBuffer.isEmpty else {
return modalBuffer