diff --git a/panels/notification/center/NormalNotify.qml b/panels/notification/center/NormalNotify.qml index 1f8d5196b..197287a1a 100644 --- a/panels/notification/center/NormalNotify.qml +++ b/panels/notification/center/NormalNotify.qml @@ -35,7 +35,7 @@ NotifyItem { date: root.date actions: root.actions defaultAction: root.defaultAction - closeVisible: impl.hovered || root.activeFocus + parentHovered: impl.hovered || root.activeFocus strongInteractive: root.strongInteractive contentIcon: root.contentIcon contentRowCount: root.contentRowCount diff --git a/panels/notification/center/NotifyCenter.qml b/panels/notification/center/NotifyCenter.qml index ae7990bb7..60da2605b 100644 --- a/panels/notification/center/NotifyCenter.qml +++ b/panels/notification/center/NotifyCenter.qml @@ -18,6 +18,25 @@ FocusScope { property alias viewPanelShown: view.viewPanelShown property int maxViewHeight: 400 property int stagingViewCount: 0 + readonly property int viewCount: view.viewCount + + signal gotoStagingLast() // Signal to Shift+Tab to staging last button + signal gotoStagingFirst() // Signal to Tab cycle to staging first item + + // Focus header first button (for Tab from staging) + function focusHeaderFirst() { + header.focusFirstButton() + } + + // Focus header last button (for Shift+Tab from staging) + function focusHeaderLast() { + header.focusLastButton() + } + + // Focus view last item (for Shift+Tab when no staging) + function focusViewLastItem() { + view.focusLastItem() + } NotifyModel { id: notifyModel @@ -40,8 +59,14 @@ FocusScope { if (view.viewCount === 0 || !view.focusItemAtIndex(0)) header.focusFirstButton() } onGotoLastNotify: { - if (view.viewCount === 0) header.focusLastButton() - else view.focusLastItem() + // First try to go to staging area if it has items + if (root.stagingViewCount > 0) { + root.gotoStagingLast() + } else if (view.viewCount === 0) { + header.focusLastButton() + } else { + view.focusLastItem() + } } } @@ -58,7 +83,14 @@ FocusScope { height: Math.min(maxViewHeight, viewHeight) notifyModel: notifyModel - onGotoHeaderFirst: header.focusFirstButton() + onGotoHeaderFirst: { + // If staging has items, go to staging first; otherwise go to header + if (root.stagingViewCount > 0) { + root.gotoStagingFirst() + } else { + header.focusFirstButton() + } + } onGotoHeaderLast: header.focusLastButton() } diff --git a/panels/notification/center/NotifyStaging.qml b/panels/notification/center/NotifyStaging.qml index 01d750f27..673204299 100644 --- a/panels/notification/center/NotifyStaging.qml +++ b/panels/notification/center/NotifyStaging.qml @@ -16,15 +16,73 @@ FocusScope { property var model: notifyModel readonly property int viewCount: view.count + signal gotoHeaderFirst() // Signal to Tab to header first button + signal gotoHeaderLast() // Signal to Shift+Tab to header last button + + // Focus the first item in staging for Tab cycle + function focusFirstItem() { + if (view.count > 0) { + view.currentIndex = 0 + Qt.callLater(function() { + let firstItem = view.itemAtIndex(0) + if (firstItem && firstItem.enabled) { + firstItem.forceActiveFocus() + } + }) + } + } + + // Focus the last button in staging for Shift+Tab from header + function focusLastButton() { + if (view.count > 0) { + let lastItem = view.itemAtIndex(view.count - 1) + if (lastItem) { + lastItem.forceActiveFocus() + Qt.callLater(function() { + if (lastItem.notifyContent) { + lastItem.notifyContent.focusLastButton() + } + }) + } + } + } + NotifyStagingModel { id: notifyModel } + // Navigate to next item or go to header + function navigateToNextItem(currentIndex) { + if (currentIndex < view.count - 1) { + view.currentIndex = currentIndex + 1 + Qt.callLater(function() { + let nextItem = view.itemAtIndex(currentIndex + 1) + if (nextItem && nextItem.enabled) nextItem.forceActiveFocus() + }) + } else { + // Last item, go to header + root.gotoHeaderFirst() + } + } + + // Navigate to previous item or go to header + function navigateToPrevItem(currentIndex) { + if (currentIndex > 0) { + view.currentIndex = currentIndex - 1 + Qt.callLater(function() { + let prevItem = view.itemAtIndex(currentIndex - 1) + if (prevItem && prevItem.enabled) prevItem.forceActiveFocus() + }) + } else { + // First item, go to header last button + root.gotoHeaderLast() + } + } + ListView { id: view spacing: 10 snapMode: ListView.SnapToItem - // activeFocusOnTab: true width: root.width height: contentHeight @@ -48,6 +106,25 @@ FocusScope { contentIcon: model.contentIcon contentRowCount: model.contentRowCount + // Tab key navigation: focus internal buttons first + Keys.onTabPressed: function(event) { + if (overlapNotify.focusFirstButton()) { + event.accepted = true + return + } + Qt.callLater(function() { + if (overlapNotify.focusFirstButton()) return + root.navigateToNextItem(index) + }) + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + root.navigateToPrevItem(index) + event.accepted = true + } + onGotoNextItem: root.navigateToNextItem(index) + onGotoPrevItem: overlapNotify.forceActiveFocus() + onRemove: function () { console.log("remove overlap", model.id) notifyModel.closeNotify(model.id, NotifyItem.Closed) diff --git a/panels/notification/center/OverlapNotify.qml b/panels/notification/center/OverlapNotify.qml index 96e58a83c..228dc73ce 100644 --- a/panels/notification/center/OverlapNotify.qml +++ b/panels/notification/center/OverlapNotify.qml @@ -66,7 +66,8 @@ NotifyItem { date: root.date actions: root.actions defaultAction: root.defaultAction - closeVisible: impl.hovered || root.activeFocus + // Don't override closeVisible - use parentHovered to pass external hover/focus state + parentHovered: impl.hovered || root.activeFocus strongInteractive: root.strongInteractive contentIcon: root.contentIcon contentRowCount: root.contentRowCount diff --git a/panels/notification/center/package/main.qml b/panels/notification/center/package/main.qml index c63f4790c..372c1e925 100644 --- a/panels/notification/center/package/main.qml +++ b/panels/notification/center/package/main.qml @@ -101,6 +101,9 @@ Window { right: parent.right rightMargin: contentPadding } + // Tab navigation: staging -> header + onGotoHeaderFirst: notifyCenter.focusHeaderFirst() + onGotoHeaderLast: notifyCenter.focusHeaderLast() Connections { target: Panel function onVisibleChanged() { @@ -125,6 +128,24 @@ Window { right: parent.right bottom: parent.bottom } + // Shift+Tab navigation: header -> staging (or view last item if no staging) + onGotoStagingLast: { + if (notifyStaging.viewCount > 0) { + notifyStaging.focusLastButton() + } else if (notifyCenter.viewCount > 0) { + notifyCenter.focusViewLastItem() + } else { + notifyCenter.focusHeaderLast() + } + } + // Tab cycle: view last item -> staging first item + onGotoStagingFirst: { + if (notifyStaging.viewCount > 0) { + notifyStaging.focusFirstItem() + } else { + notifyCenter.focusHeaderFirst() + } + } Connections { target: Panel diff --git a/panels/notification/plugin/NotifyItemContent.qml b/panels/notification/plugin/NotifyItemContent.qml index 705889337..edf68b25c 100644 --- a/panels/notification/plugin/NotifyItemContent.qml +++ b/panels/notification/plugin/NotifyItemContent.qml @@ -15,7 +15,8 @@ NotifyItem { implicitHeight: impl.implicitHeight // Maximum retry attempts for focus operations when loader content is pending readonly property int maxFocusRetries: 5 - property bool closeVisible: activeFocus || impl.hovered || (clearLoader.item && clearLoader.item.activeFocus) + property bool parentHovered: false // External hover state from parent component + property bool closeVisible: activeFocus || impl.hovered || parentHovered || (clearLoader.item && clearLoader.item.activeFocus) property int miniContentHeight: NotifyStyle.contentItem.miniHeight property bool enableDismissed: true property alias clearButton: clearLoader.sourceComponent @@ -102,7 +103,8 @@ NotifyItem { id: clearLoader anchors.right: parent.right // Show when mouse hovers or notification item has focus - active: !(root.strongInteractive && root.actions.length > 0) && (root.closeVisible || closePlaceHolder.hovered) + // Keep active when button itself has focus to prevent unloading during Tab navigation + active: !(root.strongInteractive && root.actions.length > 0) && (root.closeVisible || closePlaceHolder.hovered || (clearLoader.item && clearLoader.item.activeFocus)) sourceComponent: SettingActionButton { id: closeBtn objectName: "closeNotify-" + root.appName