Give every hero toggle a keyboard focus ring

Each panel's hero on/off toggle (Tailscale, Dropbox Wi-Fi, Bluetooth,
network, audio mute) now has a reachable "header" cursor section, a focus
ring around the hero icon, Enter/Space activation, a letter shortcut, and
hover parity. The hero is inset by heroRingPad so the ring stays inside
the Flickable/ScrollView clip box instead of being cut off.

Bluetooth and audio get a virtual "header" section above their device
sections, so the adapter/mute can be toggled by keyboard even when no
device rows exist. Network repurposes its previously dead header section.

For the PanelHero-based panels (Tailscale, Dropbox), route panel state
through the wrapper's `header` id: inside a PanelHero iconComponent `root`
resolves to PanelHero, not the Panel, so `root.headerHasCursor` and
`root.setHeaderCursor()` silently referenced the wrong object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-20 15:50:05 -07:00
co-authored by Claude Opus 4.8
parent 835181f112
commit 228b5ac3ba
5 changed files with 224 additions and 45 deletions
+30 -6
View File
@@ -48,6 +48,8 @@ Panel {
readonly property var exitNodes: displayExitNodes()
readonly property bool showExitNodes: tailscale.active && (exitNodes.length > 0 || tailscale.mullvadRegions.length > 0)
readonly property var filteredMullvadRegions: filteredMullvadRegionNodes()
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property color iconColor: tailscale.active ? foreground : dim
readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55)
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
@@ -317,6 +319,12 @@ Panel {
focusSection = "auth"
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
headerIndex = 0
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
@@ -429,11 +437,18 @@ Panel {
Item {
id: header
width: parent.width
implicitHeight: hero.implicitHeight
implicitHeight: hero.implicitHeight + root.heroRingPad
// Exposed for the hero's iconComponent, whose `root` resolves to
// PanelHero (not this Panel) — reach panel state via `header`.
readonly property bool ringVisible: root.headerHasCursor
readonly property int ringPad: root.heroRingPad
function focusHero() { root.setHeaderCursor() }
PanelHero {
id: hero
width: parent.width
x: root.heroRingPad
y: root.heroRingPad
width: parent.width - root.heroRingPad
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected"
foreground: root.foreground
@@ -444,6 +459,18 @@ Panel {
implicitWidth: icon.implicitWidth
implicitHeight: icon.implicitHeight
// Keyboard focus ring around the hero toggle. The hero is
// inset by heroRingPad so this ring stays inside the
// Flickable's clip box instead of being cut off.
BorderSurface {
anchors.fill: icon
anchors.margins: -header.ringPad
color: "transparent"
radius: Style.cornerRadius
visible: header.ringVisible
borderSpec: Border.controlSpec("hover-cursor", hero.foreground, Color.accent)
}
TailscaleIcon {
id: icon
iconSize: Style.font.display
@@ -460,10 +487,7 @@ Panel {
hoverEnabled: true
enabled: tailscale.installed && !tailscale.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "header"
root.headerIndex = 0
}
onContainsMouseChanged: if (containsMouse) header.focusHero()
onClicked: tailscale.toggleTailscale()
}