From 1ad707b81efff37a1ed92b56184a60e8b712b3d1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 20 Jul 2026 15:50:05 -0700 Subject: [PATCH] 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) --- Panel.qml | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/Panel.qml b/Panel.qml index 7c51bab..f188ca7 100644 --- a/Panel.qml +++ b/Panel.qml @@ -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() }