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
15 changes: 14 additions & 1 deletion Sources/SwiflowRouter/Web/BrowserNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,20 @@ package final class BrowserNavigator: Navigator {
/// The event name registered alongside `listenerValue`.
private var listenerEvent: String?

package init() {}
package init() {
// canImport(JavaScriptKit) is true on host, and the first
// JSObject.global access there aborts with NO message — the
// accessor fatalErrors below can never print on host. Fail at
// construction instead, where a diagnostic still reaches the
// terminal.
#if !arch(wasm32)
fatalError(
"Swiflow router: BrowserNavigator requires a browser environment. "
+ "Host tests must inject a Navigator (RouterRoot's package "
+ "init with a MockNavigator)."
)
#endif
}

private var window: JSObject {
guard let w = JSObject.global.window.object else {
Expand Down
8 changes: 5 additions & 3 deletions Sources/SwiflowUI/Dropdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,13 @@ final class DropdownMenu {
}

/// The imperative half: apply the roving decision through the DOM.
/// `#if canImport(JavaScriptKit)`-guarded (a no-op on host), mirroring
/// Autocomplete's focus-by-id.
/// The JS crossing is keyed on `arch(wasm32)`, NOT canImport — canImport
/// is true on host, where merely touching `JSObject.global` aborts with
/// no message. A host keydown still computes the decision; only the DOM
/// focus move is wasm-only.
private static func rove(_ e: EventInfo, current: String, order: [String], menuID: String) {
guard let action = roveTarget(key: e.key, current: current, order: order) else { return }
#if canImport(JavaScriptKit)
#if arch(wasm32)
guard let doc = JSObject.global.document.object else { return }
switch action {
case .closeMenu:
Expand Down
7 changes: 4 additions & 3 deletions Sources/SwiflowUI/Tabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,13 @@ final class TabsView {

/// The imperative half: on a roving key, select the target tab (automatic
/// activation — re-renders, moving `tabindex`/`hidden`), then move DOM focus onto
/// it. `#if canImport(JavaScriptKit)`-guarded (a no-op on host), mirroring
/// `DropdownMenu.rove`.
/// it. Selection stays outside the gate so host tests can drive the roving
/// semantics; the focus move is keyed on `arch(wasm32)`, NOT canImport —
/// canImport is true on host, where touching `JSObject.global` aborts.
private func handleRove(_ e: EventInfo, current: Int) {
guard let target = TabsView.tabRoveTarget(key: e.key, current: current, count: labels.count) else { return }
selectIndex(target)
#if canImport(JavaScriptKit)
#if arch(wasm32)
guard let doc = JSObject.global.document.object else { return }
if let el = doc.getElementById?("\(idPrefix)-tab-\(target)").object { _ = el.focus?() }
#endif
Expand Down
6 changes: 5 additions & 1 deletion Tests/SwiflowTests/TaskDiffTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,18 @@ struct TaskDiffTests {
var captured: [String] = []
let prior = _swiflowDiagnosticOverride
_swiflowDiagnosticOverride = { captured.append($0) }
defer { _swiflowDiagnosticOverride = prior }

// New render declares two tasks where there was one — stable-slot violation.
inScope {
reconcileTasks(on: node,
new: [TaskBinding(dependency: AnyEquatableBox(1), body: {}),
TaskBinding(dependency: nil, body: {})])
}
// The diagnostic fires synchronously inside reconcileTasks, so restore
// BEFORE the await: a process-global override held across a suspension
// lets parallel suites interleave installs and break the LIFO restore
// (the seam's synchronous-hold invariant — see Diagnostics.swift).
_swiflowDiagnosticOverride = prior
await drain()
#expect(captured.contains { $0.contains("`.task` count") })
// The grow path still ran past the diagnostic and started the extra slot.
Expand Down
57 changes: 57 additions & 0 deletions Tests/SwiflowUITests/TabsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Testing
@testable import Swiflow // HandlerAmbient / HandlerRegistry / EventInfo for the click dispatch
@testable import SwiflowUI
import SwiflowTesting // live-harness keyboard-roving suite

@MainActor private func el(_ node: VNode?) -> ElementData? {
if case .element(let data)? = node { return data }
Expand Down Expand Up @@ -298,3 +299,59 @@ struct TabsTests {
#expect(css.contains(".sw-tabs__tab[aria-selected=\"true\"] { border-bottom-color: transparent; }"))
}
}

// MARK: - Live keyboard roving (harness-mounted)

/// Hosts a real Tabs in the headless harness so keydown drives the FULL
/// path — handleRove's selection move runs on host (only the DOM focus()
/// crossing is arch(wasm32)-gated). Before that gate, a host keydown
/// aborted in JSObject.global with no message, which is why no such test
/// could exist.
@Component
private final class TabsRovingHost {
@State var selection: String = "one"
var body: VNode {
Tabs(selection: $selection) {
Tab("One", id: "one") { text("first") }
Tab("Two", id: "two") { text("second") }
Tab("Three", id: "three") { text("third") }
}
}
}

@Suite("Tabs live keyboard roving")
@MainActor
struct TabsRovingTests {

private func selectedLabel(_ h: TestHarness) -> String? {
h.findAll(role: "tab").first { $0.attributes["aria-selected"] == "true" }?.text
}

@Test("ArrowRight/ArrowLeft/Home/End move the selection (automatic activation), wrapping")
func arrowKeysMoveSelection() {
let h = render(TabsRovingHost())
#expect(selectedLabel(h) == "One")

h.findAll(role: "tab")[0].press(key: "ArrowRight")
#expect(selectedLabel(h) == "Two")

h.findAll(role: "tab")[1].press(key: "End")
#expect(selectedLabel(h) == "Three")

h.findAll(role: "tab")[2].press(key: "ArrowRight") // wraps
#expect(selectedLabel(h) == "One")

h.findAll(role: "tab")[0].press(key: "ArrowLeft") // wraps back
#expect(selectedLabel(h) == "Three")

h.findAll(role: "tab")[2].press(key: "Home")
#expect(selectedLabel(h) == "One")
}

@Test("a non-roving key leaves the selection alone")
func otherKeysIgnored() {
let h = render(TabsRovingHost())
h.findAll(role: "tab")[0].press(key: "Enter")
#expect(selectedLabel(h) == "One")
}
}
Loading