diff --git a/panels/notification/center/GroupNotify.qml b/panels/notification/center/GroupNotify.qml index 5420bedf3..dbd13c940 100644 --- a/panels/notification/center/GroupNotify.qml +++ b/panels/notification/center/GroupNotify.qml @@ -15,6 +15,14 @@ NotifyItem { implicitHeight: impl.implicitHeight signal collapse() + signal gotoNextItem() // Signal to navigate to next notify item + signal gotoPrevItem() // Signal to navigate to previous notify item + + // Focus the first button for Tab navigation into group + function focusFirstButton() { + foldBtn.forceActiveFocus() + return true + } Control { id: impl @@ -34,18 +42,40 @@ NotifyItem { } AnimationSettingButton { + id: foldBtn Layout.alignment: Qt.AlignRight + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "fold" text: qsTr("Fold") + Keys.onTabPressed: function(event) { + groupMoreBtn.forceActiveFocus() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + root.gotoPrevItem() + event.accepted = true + } onClicked: { console.log("collapse") root.collapse() } } AnimationSettingButton { + id: groupMoreBtn Layout.alignment: Qt.AlignRight + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "more" text: qsTr("More") + Keys.onTabPressed: function(event) { + groupClearBtn.forceActiveFocus() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + foldBtn.forceActiveFocus() + event.accepted = true + } onClicked: function () { console.log("group setting", root.appName) let pos = mapToItem(root, Qt.point(width / 2, height)) @@ -53,9 +83,21 @@ NotifyItem { } } AnimationSettingButton { + id: groupClearBtn Layout.alignment: Qt.AlignRight + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "clean-group" text: qsTr("Clear All") + Keys.onTabPressed: function(event) { + groupClearBtn.focus = false // Clear focus before signal to prevent focus state residue + root.gotoNextItem() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + groupMoreBtn.forceActiveFocus() + event.accepted = true + } onClicked: function () { root.remove() } diff --git a/panels/notification/center/NormalNotify.qml b/panels/notification/center/NormalNotify.qml index d2cfe8c1a..1f8d5196b 100644 --- a/panels/notification/center/NormalNotify.qml +++ b/panels/notification/center/NormalNotify.qml @@ -14,11 +14,19 @@ NotifyItem { implicitWidth: impl.implicitWidth implicitHeight: impl.implicitHeight + signal gotoNextItem() + signal gotoPrevItem() + + function focusFirstButton() { + return notifyContent.focusFirstButton() + } + Control { id: impl anchors.fill: parent contentItem: NotifyItemContent { + id: notifyContent width: parent.width appName: root.appName iconName: root.iconName @@ -42,6 +50,8 @@ NotifyItem { onActionInvoked: function (actionId) { root.actionInvoked(actionId) } + onGotoNextItem: root.gotoNextItem() + onGotoPrevItem: root.gotoPrevItem() } } } diff --git a/panels/notification/center/NotifyCenter.qml b/panels/notification/center/NotifyCenter.qml index fb161d5dd..ae7990bb7 100644 --- a/panels/notification/center/NotifyCenter.qml +++ b/panels/notification/center/NotifyCenter.qml @@ -36,6 +36,13 @@ FocusScope { width: NotifyStyle.contentItem.width notifyModel: notifyModel z: 1 + onGotoFirstNotify: { + if (view.viewCount === 0 || !view.focusItemAtIndex(0)) header.focusFirstButton() + } + onGotoLastNotify: { + if (view.viewCount === 0) header.focusLastButton() + else view.focusLastItem() + } } NotifyView { @@ -51,6 +58,8 @@ FocusScope { height: Math.min(maxViewHeight, viewHeight) notifyModel: notifyModel + onGotoHeaderFirst: header.focusFirstButton() + onGotoHeaderLast: header.focusLastButton() } DropShadowText { diff --git a/panels/notification/center/NotifyHeader.qml b/panels/notification/center/NotifyHeader.qml index 7983f7a20..8f2fcf3b3 100644 --- a/panels/notification/center/NotifyHeader.qml +++ b/panels/notification/center/NotifyHeader.qml @@ -11,9 +11,38 @@ import org.deepin.ds.notificationcenter FocusScope { id: root + activeFocusOnTab: true required property NotifyModel notifyModel + signal gotoFirstNotify() // Signal to Tab to first notify item + signal gotoLastNotify() // Signal to Shift+Tab to last notify item + + // Forward focus to first button when FocusScope receives focus + onActiveFocusChanged: { + if (activeFocus && !collapseBtn.activeFocus && !moreBtn.activeFocus && !clearAllBtn.activeFocus) { + if (collapseBtn.visible) { + collapseBtn.forceActiveFocus() + } else { + moreBtn.forceActiveFocus() + } + } + } + + // Focus the first visible button in header for Tab navigation + function focusFirstButton() { + if (collapseBtn.visible) { + collapseBtn.forceActiveFocus() + } else { + moreBtn.forceActiveFocus() + } + } + + // Focus the last button in header for Shift+Tab navigation + function focusLastButton() { + clearAllBtn.forceActiveFocus() + } + RowLayout { anchors.fill: parent NotifyHeaderTitleText { @@ -29,12 +58,22 @@ FocusScope { } AnimationSettingButton { + id: collapseBtn objectName: "collapse" - focus: true visible: !notifyModel.collapse Layout.alignment: Qt.AlignRight + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "fold" text: qsTr("Fold") + Keys.onTabPressed: function(event) { + moreBtn.forceActiveFocus() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + root.gotoLastNotify() + event.accepted = true + } onClicked: function () { console.log("Collapse all notify") notifyModel.collapseAllApp() @@ -42,11 +81,25 @@ FocusScope { } AnimationSettingButton { + id: moreBtn objectName: "more" - focus: true Layout.alignment: Qt.AlignRight + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "more" text: qsTr("More") + Keys.onTabPressed: function(event) { + clearAllBtn.forceActiveFocus() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + if (collapseBtn.visible) { + collapseBtn.forceActiveFocus() + } else { + root.gotoLastNotify() + } + event.accepted = true + } onClicked: function () { console.log("Notify setting") NotifyAccessor.openNotificationSetting() @@ -54,10 +107,22 @@ FocusScope { } AnimationSettingButton { + id: clearAllBtn objectName: "closeAllNotify" + activeFocusOnTab: false + focusBorderVisible: activeFocus icon.name: "clean-all" text: qsTr("Clear All") Layout.alignment: Qt.AlignRight + Keys.onTabPressed: function(event) { + clearAllBtn.focus = false // Clear focus before signal to prevent focus state residue + root.gotoFirstNotify() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + moreBtn.forceActiveFocus() + event.accepted = true + } onClicked: function () { console.log("Clear all notify") notifyModel.clear() diff --git a/panels/notification/center/NotifyView.qml b/panels/notification/center/NotifyView.qml index 3ec145556..8e11920c1 100644 --- a/panels/notification/center/NotifyView.qml +++ b/panels/notification/center/NotifyView.qml @@ -13,25 +13,60 @@ Control { id: root focus: true + // Maximum retry attempts for focus operations when delegate creation is pending + readonly property int maxFocusRetries: 5 + required property NotifyModel notifyModel property alias viewPanelShown: view.panelShown readonly property real viewHeight: view.contentHeight readonly property int viewCount: view.count + signal gotoHeaderFirst() // Signal to cycle Tab back to header first button + signal gotoHeaderLast() // Signal to cycle Shift+Tab back to header last button + NotifySetting { id: notifySetting notifyModel: root.notifyModel } + // Focus notify item at specified index with retry logic for delegate creation + function focusItemAtIndex(idx) { + if (idx < 0 || idx >= view.count) return false + view.currentIndex = idx + view.positionViewAtIndex(idx, ListView.Contain) + function tryFocus(retries) { + let item = view.itemAtIndex(idx) + if (item && item.enabled) { + item.forceActiveFocus() + } else if (retries > 0) { + Qt.callLater(function() { tryFocus(retries - 1) }) + } + } + Qt.callLater(function() { tryFocus(root.maxFocusRetries) }) + return true + } + + // Focus the last notify item for Shift+Tab cycling from header + function focusLastItem() { + if (view.count > 0) { + focusItemAtIndex(view.count - 1) + } + } + contentItem: ListView { id: view spacing: 10 snapMode: ListView.SnapToItem - // activeFocusOnTab: true + keyNavigationEnabled: false + activeFocusOnTab: false ScrollBar.vertical: ScrollBar { } property int nextIndex: -1 property bool panelShown: false - + + // Forward signals from delegate to root for Tab cycling + function gotoHeaderFirst() { root.gotoHeaderFirst() } + function gotoHeaderLast() { root.gotoHeaderLast() } + onNextIndexChanged: { if (nextIndex >= 0 && count > 0) { currentIndex = nextIndex diff --git a/panels/notification/center/NotifyViewDelegate.qml b/panels/notification/center/NotifyViewDelegate.qml index fe69b42d0..2d2573a71 100644 --- a/panels/notification/center/NotifyViewDelegate.qml +++ b/panels/notification/center/NotifyViewDelegate.qml @@ -18,6 +18,34 @@ DelegateChooser { role: "type" + // Shared navigation helper: focus next item or cycle to header + function navigateToNextItem(currentIndex) { + if (currentIndex < view.count - 1) { + view.currentIndex = currentIndex + 1 + view.positionViewAtIndex(currentIndex + 1, ListView.Contain) + Qt.callLater(function() { + let nextItem = view.itemAtIndex(currentIndex + 1) + if (nextItem && nextItem.enabled) nextItem.forceActiveFocus() + }) + } else { + view.gotoHeaderFirst() + } + } + + // Shared navigation helper: focus previous item or cycle to header + function navigateToPrevItem(currentIndex) { + if (currentIndex > 0) { + view.currentIndex = currentIndex - 1 + view.positionViewAtIndex(currentIndex - 1, ListView.Contain) + Qt.callLater(function() { + let prevItem = view.itemAtIndex(currentIndex - 1) + if (prevItem && prevItem.enabled) prevItem.forceActiveFocus() + }) + } else { + view.gotoHeaderLast() + } + } + DelegateChoice { roleValue: "group" GroupNotify { @@ -25,9 +53,21 @@ DelegateChooser { objectName: "group-" + model.appName width: NotifyStyle.contentItem.width appName: model.appName - activeFocusOnTab: true + activeFocusOnTab: false z: index + Keys.onTabPressed: function(event) { + groupNotify.focusFirstButton() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + root.navigateToPrevItem(index) + event.accepted = true + } + + onGotoNextItem: root.navigateToNextItem(index) + onGotoPrevItem: groupNotify.focusFirstButton() + Loader { anchors.fill: parent active: groupNotify.activeFocus && NotifyAccessor.debugging @@ -63,9 +103,27 @@ DelegateChooser { id: normalNotify objectName: "normal-" + model.appName width: NotifyStyle.contentItem.width - activeFocusOnTab: true + activeFocusOnTab: false z: index + Keys.onTabPressed: function(event) { + if (normalNotify.focusFirstButton()) { + event.accepted = true + return + } + Qt.callLater(function() { + if (normalNotify.focusFirstButton()) return + root.navigateToNextItem(index) + }) + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + root.navigateToPrevItem(index) + event.accepted = true + } + onGotoNextItem: root.navigateToNextItem(index) + onGotoPrevItem: normalNotify.forceActiveFocus() + appName: model.appName iconName: model.iconName date: model.time @@ -128,9 +186,27 @@ DelegateChooser { id: overlapNotify objectName: "overlap-" + model.appName width: NotifyStyle.contentItem.width - activeFocusOnTab: true + activeFocusOnTab: false z: index + 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() + count: model.overlapCount appName: model.appName iconName: model.iconName @@ -145,6 +221,16 @@ DelegateChooser { notifyContent.clearButton: AnimationSettingButton { icon.name: "clean-alone" text: qsTr("Clean All") + activeFocusOnTab: false + focusBorderVisible: activeFocus + Keys.onTabPressed: function(event) { + overlapNotify.gotoNextItem() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + overlapNotify.gotoPrevItem() + event.accepted = true + } onClicked: function () { notifyContent.remove() } diff --git a/panels/notification/center/OverlapNotify.qml b/panels/notification/center/OverlapNotify.qml index de0806f7d..96e58a83c 100644 --- a/panels/notification/center/OverlapNotify.qml +++ b/panels/notification/center/OverlapNotify.qml @@ -21,6 +21,12 @@ NotifyItem { property alias notifyContent: notifyContent signal expand() + signal gotoNextItem() + signal gotoPrevItem() + + function focusFirstButton() { + return notifyContent.focusFirstButton() + } states: [ State { @@ -75,6 +81,8 @@ NotifyItem { onActionInvoked: function (actionId) { root.actionInvoked(actionId) } + onGotoNextItem: root.gotoNextItem() + onGotoPrevItem: root.gotoPrevItem() } OverlapIndicator { diff --git a/panels/notification/plugin/NotifyAction.qml b/panels/notification/plugin/NotifyAction.qml index ebe011df7..6b6c48be9 100644 --- a/panels/notification/plugin/NotifyAction.qml +++ b/panels/notification/plugin/NotifyAction.qml @@ -14,19 +14,57 @@ Control { property var actions: [] signal actionInvoked(var actionId) + signal gotoNextButton() // Signal to Tab to next button (X button or next notify) + signal gotoPrevItem() // Signal to Shift+Tab to previous notify item + + // Focus the first action button for Tab navigation + function focusFirstButton() { + if (actions.length > 0) { + firstActionBtn.forceActiveFocus() + } + } + + // Focus the last action button for Shift+Tab navigation + function focusLastButton() { + if (actions.length === 2 && secondActionLoader.item) { + secondActionLoader.item.forceActiveFocus() + } else if (actions.length > 2 && moreActionsLoader.item) { + moreActionsLoader.item.forceActiveFocus() + } else if (actions.length > 0) { + firstActionBtn.forceActiveFocus() + } + } contentItem: RowLayout { spacing: 5 height: 30 NotifyActionButton { - actionData: actions[0] + id: firstActionBtn + actionData: actions.length > 0 ? actions[0] : null + visible: actions.length > 0 + activeFocusOnTab: false Layout.maximumWidth: 120 Layout.preferredHeight: 30 Layout.fillHeight: true Layout.alignment: Qt.AlignHCenter + Keys.onBacktabPressed: function(event) { + root.gotoPrevItem() + event.accepted = true + } + Keys.onTabPressed: function(event) { + if (actions.length === 1) { + root.gotoNextButton() + } else if (actions.length === 2 && secondActionLoader.item) { + secondActionLoader.item.forceActiveFocus() + } else if (actions.length > 2 && moreActionsLoader.item) { + moreActionsLoader.item.forceActiveFocus() + } + event.accepted = true + } } Loader { + id: secondActionLoader active: actions.length === 2 visible: active Layout.maximumWidth: 120 @@ -34,11 +72,21 @@ Control { Layout.fillHeight: true Layout.alignment: Qt.AlignHCenter sourceComponent: NotifyActionButton { - actionData: actions[1] + actionData: actions.length > 1 ? actions[1] : null + activeFocusOnTab: false + Keys.onBacktabPressed: function(event) { + firstActionBtn.forceActiveFocus() + event.accepted = true + } + Keys.onTabPressed: function(event) { + root.gotoNextButton() + event.accepted = true + } } } Loader { + id: moreActionsLoader active: actions.length > 2 visible: active Layout.maximumWidth: 200 @@ -49,10 +97,20 @@ Control { implicitHeight: 30 implicitWidth: 160 model: expandActions + activeFocusOnTab: false + Keys.onBacktabPressed: function(event) { + firstActionBtn.forceActiveFocus() + event.accepted = true + } + Keys.onTabPressed: function(event) { + root.gotoNextButton() + event.accepted = true + } delegate: NotifyActionButton { required property int index width: parent.width actionData: expandActions[index] + activeFocusOnTab: false } } } @@ -61,7 +119,7 @@ Control { component NotifyActionButton: Button { id: actionButton required property var actionData - text: actionData.text + text: actionData ? actionData.text : "" topPadding: undefined bottomPadding: undefined leftPadding: undefined @@ -70,8 +128,10 @@ Control { spacing: 0 font: DTK.fontManager.t6 onClicked: { - console.log("action invoked", actionData.id) - actionInvoked(actionData.id) + if (actionData) { + console.log("action invoked", actionData.id) + actionInvoked(actionData.id) + } } Loader { diff --git a/panels/notification/plugin/NotifyItemContent.qml b/panels/notification/plugin/NotifyItemContent.qml index e9f7fb4aa..705889337 100644 --- a/panels/notification/plugin/NotifyItemContent.qml +++ b/panels/notification/plugin/NotifyItemContent.qml @@ -13,11 +13,50 @@ NotifyItem { id: root implicitWidth: impl.implicitWidth implicitHeight: impl.implicitHeight - property bool closeVisible: activeFocus || impl.hovered + // 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 int miniContentHeight: NotifyStyle.contentItem.miniHeight property bool enableDismissed: true property alias clearButton: clearLoader.sourceComponent + signal gotoNextItem() // Signal to navigate to next notify item + signal gotoPrevItem() // Signal to navigate to previous notify item + + // Focus first interactive button (action buttons first, then X button) + function focusFirstButton() { + if (actionLoader.item && actionLoader.item.enabled) { + actionLoader.item.focusFirstButton() + return true + } + if (clearLoader.item && clearLoader.item.enabled) { + clearLoader.item.forceActiveFocus() + return true + } + // Retry if clearLoader not yet created + function tryFocusClear(retries) { + if (clearLoader.item && clearLoader.item.enabled) { + clearLoader.item.forceActiveFocus() + } else if (retries > 0) { + Qt.callLater(function() { tryFocusClear(retries - 1) }) + } + } + Qt.callLater(function() { tryFocusClear(root.maxFocusRetries) }) + return true + } + + // Focus last interactive button (X button first, then action buttons) + function focusLastButton() { + if (clearLoader.item && clearLoader.item.enabled) { + clearLoader.item.forceActiveFocus() + return true + } else if (actionLoader.item && actionLoader.item.enabled) { + actionLoader.item.focusLastButton() + return true + } + return false + } + Control { id: impl anchors.fill: parent @@ -61,15 +100,28 @@ NotifyItem { Loader { id: clearLoader - focus: true anchors.right: parent.right - active: !(root.strongInteractive && root.actions.length > 0) && (root.closeVisible || closePlaceHolder.hovered || activeFocus) + // Show when mouse hovers or notification item has focus + active: !(root.strongInteractive && root.actions.length > 0) && (root.closeVisible || closePlaceHolder.hovered) sourceComponent: SettingActionButton { id: closeBtn objectName: "closeNotify-" + root.appName icon.name: "clean-alone" padding: 2 - forcusBorderVisible: visualFocus + activeFocusOnTab: false + focusBorderVisible: activeFocus + Keys.onTabPressed: function(event) { + root.gotoNextItem() + event.accepted = true + } + Keys.onBacktabPressed: function(event) { + if (actionLoader.item) { + actionLoader.item.focusLastButton() + } else { + root.gotoPrevItem() + } + event.accepted = true + } onClicked: function () { root.remove() } @@ -254,6 +306,7 @@ NotifyItem { } Loader { + id: actionLoader active: root.actions.length > 0 visible: active Layout.alignment: Qt.AlignRight | Qt.AlignBottom @@ -262,6 +315,15 @@ NotifyItem { onActionInvoked: function (actionId) { root.actionInvoked(actionId) } + onGotoNextButton: { + // Navigate to clear button or next notification item + if (clearLoader.item) { + clearLoader.item.forceActiveFocus() + } else { + root.gotoNextItem() + } + } + onGotoPrevItem: root.gotoPrevItem() } } } diff --git a/panels/notification/plugin/SettingActionButton.qml b/panels/notification/plugin/SettingActionButton.qml index af6d5c24b..a696ef9aa 100644 --- a/panels/notification/plugin/SettingActionButton.qml +++ b/panels/notification/plugin/SettingActionButton.qml @@ -11,7 +11,7 @@ ActionButton { id: root property int radius: 15 - property bool forcusBorderVisible: root.visualFocus + property bool focusBorderVisible: root.visualFocus icon.width: 16 icon.height: 16 @@ -20,7 +20,7 @@ ActionButton { Loader { anchors.fill: parent - active: root.forcusBorderVisible + active: root.focusBorderVisible sourceComponent: FocusBoxBorder { radius: root.radius