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
+33 -1
View File
@@ -127,6 +127,11 @@ Panel {
property int selectedIndex: -1
property bool cursorActive: false
// "header" is a virtual section for the hero output mute toggle; it sits
// above the output section so the speaker can be muted from the keyboard.
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property color hoverFill: bar
? Style.hoverFillFor(bar.foreground, Color.accent)
: "transparent"
@@ -167,6 +172,10 @@ Panel {
function moveCursor(delta) {
var sections = visibleSections
if (sections.length === 0) return
if (focusSection === "header") {
if (delta > 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(sections[0]) ? -1 : 0 }
return
}
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return }
@@ -189,10 +198,18 @@ Panel {
focusSection = sections[sIdx - 1]
var prevMax = sectionCount(focusSection) - 1
selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0)
} else {
focusSection = "header"
}
}
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
selectedIndex = -1
}
function moveSection(delta) {
var sections = visibleSections
if (sections.length === 0) return
@@ -227,6 +244,7 @@ Panel {
// Enter/Space: activate whatever the cursor is on.
function activateCursor() {
if (focusSection === "header") { toggleOutputMute(); return }
if (focusSection === "output") {
if (selectedIndex === -1) { toggleOutputMute(); return }
var sink = displayAudioSinks[selectedIndex]
@@ -597,7 +615,18 @@ Panel {
Item {
id: heroItem
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) + root.heroRingPad * 2
// Keyboard focus ring around the hero output-mute toggle. heroIcon
// is inset by heroRingPad so this ring stays inside the clip box.
BorderSurface {
anchors.fill: heroIcon
anchors.margins: -root.heroRingPad
color: "transparent"
radius: Style.cornerRadius
visible: root.headerHasCursor
borderSpec: Border.controlSpec("hover-cursor", root.bar.foreground, Color.accent)
}
Text {
id: heroIcon
@@ -607,11 +636,14 @@ Panel {
font.pixelSize: Style.font.display
opacity: root.outputMuted ? 0.5 : 1.0
anchors.left: parent.left
anchors.leftMargin: root.heroRingPad
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.setHeaderCursor()
onClicked: root.toggleOutputMute()
}
}