Skip to content
/ TUIkit Public

Commit a4c437f

Browse files
committed
Fix: Tab-Navigation und Arrow-Key-Wrap in NavigationSplitView
- Tab navigates within the active section first; only switches to the next section when the current element is the last in its section - Arrow keys (Up/Down) no longer wrap at section boundaries - moveFocusInSection gains a wrap: Bool parameter and returns Bool - Add tests: tabNavigatesWithinSectionFirst, arrowKeysDoNotWrapAtBoundary
1 parent c2019a5 commit a4c437f

2 files changed

Lines changed: 115 additions & 15 deletions

File tree

Sources/TUIkit/Focus/Focus.swift

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -213,36 +213,46 @@ public extension FocusManager {
213213
}
214214

215215
/// Moves focus to the next element within the active section.
216+
///
217+
/// Arrow-key navigation: does **not** wrap at the boundary.
216218
func focusNextInSection() {
217-
moveFocusInSection(direction: .forward)
219+
moveFocusInSection(direction: .forward, wrap: false)
218220
}
219221

220222
/// Moves focus to the previous element within the active section.
223+
///
224+
/// Arrow-key navigation: does **not** wrap at the boundary.
221225
func focusPreviousInSection() {
222-
moveFocusInSection(direction: .backward)
226+
moveFocusInSection(direction: .backward, wrap: false)
223227
}
224228

225229
/// Moves focus to the next focusable element.
226230
///
227-
/// When multiple sections exist, this cycles between sections.
228-
/// When only one section exists, this cycles within it.
231+
/// When multiple sections exist, Tab navigates within the current section
232+
/// first. Only when the current element is the last in its section does
233+
/// Tab switch to the next section.
234+
/// When only one section exists, this cycles within it (wrapping).
229235
func focusNext() {
230236
if sections.count > 1 {
231-
activateNextSection()
237+
let moved = moveFocusInSection(direction: .forward, wrap: false)
238+
if !moved { activateNextSection() }
232239
} else {
233-
moveFocusInSection(direction: .forward)
240+
moveFocusInSection(direction: .forward, wrap: true)
234241
}
235242
}
236243

237244
/// Moves focus to the previous focusable element.
238245
///
239-
/// When multiple sections exist, this cycles between sections.
240-
/// When only one section exists, this cycles within it.
246+
/// When multiple sections exist, Shift+Tab navigates within the current
247+
/// section first. Only when the current element is the first in its section
248+
/// does Shift+Tab switch to the previous section.
249+
/// When only one section exists, this cycles within it (wrapping).
241250
func focusPrevious() {
242251
if sections.count > 1 {
243-
activatePreviousSection()
252+
let moved = moveFocusInSection(direction: .backward, wrap: false)
253+
if !moved { activatePreviousSection() }
244254
} else {
245-
moveFocusInSection(direction: .backward)
255+
moveFocusInSection(direction: .backward, wrap: true)
246256
}
247257
}
248258

@@ -463,26 +473,47 @@ private extension FocusManager {
463473
}
464474

465475
/// Moves focus within the active section.
466-
func moveFocusInSection(direction: FocusDirection) {
467-
guard let section = activeSection else { return }
476+
///
477+
/// - Parameters:
478+
/// - direction: The direction in which to move focus.
479+
/// - wrap: When `true`, focus wraps around from the last element to the
480+
/// first (and vice versa). When `false`, focus stops at the boundary
481+
/// and the method returns `false`.
482+
/// - Returns: `true` if focus moved to a new element, `false` if the
483+
/// boundary was reached (and `wrap` is `false`) or no element is available.
484+
@discardableResult
485+
func moveFocusInSection(direction: FocusDirection, wrap: Bool = true) -> Bool {
486+
guard let section = activeSection else { return false }
468487

469488
let available = section.focusables.filter { $0.canBeFocused }
470-
guard !available.isEmpty else { return }
489+
guard !available.isEmpty else { return false }
471490

472491
if let currentID = focusedID,
473492
let currentIndex = available.firstIndex(where: { $0.focusID == currentID })
474493
{
475494
let targetIndex: Int
476495
switch direction {
477496
case .forward:
478-
targetIndex = (currentIndex + 1) % available.count
497+
if currentIndex == available.count - 1 {
498+
guard wrap else { return false }
499+
targetIndex = 0
500+
} else {
501+
targetIndex = currentIndex + 1
502+
}
479503
case .backward:
480-
targetIndex = currentIndex == 0 ? available.count - 1 : currentIndex - 1
504+
if currentIndex == 0 {
505+
guard wrap else { return false }
506+
targetIndex = available.count - 1
507+
} else {
508+
targetIndex = currentIndex - 1
509+
}
481510
}
482511
focus(available[targetIndex])
512+
return true
483513
} else {
484514
let fallbackIndex = direction == .forward ? 0 : available.count - 1
485515
focus(available[fallbackIndex])
516+
return true
486517
}
487518
}
488519

Tests/TUIkitTests/FocusSectionTests.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,75 @@ struct FocusSectionTests {
196196
#expect(manager.isFocused(pageBtn))
197197
}
198198

199+
@Test("Tab navigates within section before switching to next section")
200+
func tabNavigatesWithinSectionFirst() {
201+
let manager = FocusManager()
202+
203+
manager.registerSection(id: "sidebar")
204+
manager.registerSection(id: "detail")
205+
206+
let sidebarBtn = MockFocusable(id: "sidebar-btn")
207+
let detailBtn1 = MockFocusable(id: "detail-btn-1")
208+
let detailBtn2 = MockFocusable(id: "detail-btn-2")
209+
210+
manager.register(sidebarBtn, inSection: "sidebar")
211+
manager.register(detailBtn1, inSection: "detail")
212+
manager.register(detailBtn2, inSection: "detail")
213+
214+
// Switch to detail section with two elements
215+
manager.activateSection(id: "detail")
216+
#expect(manager.isActiveSection("detail"))
217+
#expect(manager.isFocused(detailBtn1))
218+
219+
let tabEvent = KeyEvent(key: .tab, ctrl: false, alt: false, shift: false)
220+
221+
// Tab → stays in detail, focuses second element
222+
manager.dispatchKeyEvent(tabEvent)
223+
#expect(manager.isActiveSection("detail"), "Tab should stay in detail when not at last element")
224+
#expect(manager.isFocused(detailBtn2))
225+
226+
// Tab again → now at last element, switches to next section (sidebar)
227+
manager.dispatchKeyEvent(tabEvent)
228+
#expect(manager.isActiveSection("sidebar"), "Tab at last element should switch to next section")
229+
#expect(manager.isFocused(sidebarBtn))
230+
}
231+
232+
@Test("Arrow keys do not wrap at section boundary")
233+
func arrowKeysDoNotWrapAtBoundary() {
234+
let manager = FocusManager()
235+
236+
manager.registerSection(id: "panel")
237+
238+
let item1 = MockFocusable(id: "item-1")
239+
let item2 = MockFocusable(id: "item-2")
240+
let item3 = MockFocusable(id: "item-3")
241+
242+
manager.register(item1, inSection: "panel")
243+
manager.register(item2, inSection: "panel")
244+
manager.register(item3, inSection: "panel")
245+
246+
// Navigate to last element
247+
#expect(manager.isFocused(item1))
248+
let downEvent = KeyEvent(key: .down, ctrl: false, alt: false, shift: false)
249+
manager.dispatchKeyEvent(downEvent)
250+
manager.dispatchKeyEvent(downEvent)
251+
#expect(manager.isFocused(item3))
252+
253+
// Down at last element: stays at item3 (no wrap)
254+
manager.dispatchKeyEvent(downEvent)
255+
#expect(manager.isFocused(item3), "Down arrow at last element should not wrap")
256+
257+
// Navigate to first element
258+
let upEvent = KeyEvent(key: .up, ctrl: false, alt: false, shift: false)
259+
manager.dispatchKeyEvent(upEvent)
260+
manager.dispatchKeyEvent(upEvent)
261+
#expect(manager.isFocused(item1))
262+
263+
// Up at first element: stays at item1 (no wrap)
264+
manager.dispatchKeyEvent(upEvent)
265+
#expect(manager.isFocused(item1), "Up arrow at first element should not wrap")
266+
}
267+
199268
@Test("Single section: Tab cycles elements within it")
200269
func singleSectionTabCyclesElements() {
201270
let manager = FocusManager()

0 commit comments

Comments
 (0)