From 317d2e1bbba8eeb88b8a4bfdc3c5d368f015336e Mon Sep 17 00:00:00 2001 From: Lorn Potter Date: Tue, 15 Sep 2026 12:54:08 +1000 Subject: TableView: follow the current cell with accessibility focus Keyboard navigation moves the current cell of a TableView, but screen readers did not follow it. TableView is not an accessible item, and its cell delegates do not take focus while navigating (only the edit delegate does, while editing), so assistive technology never saw a focus event. Send a focus event for the delegate of the current cell while the view has active focus, also when that delegate is only loaded after the current index changed, and again when an edit ends and focus returns to the view. Add QAccessibleQuickTableView, whose focusChild() returns that same delegate, for platforms such as macOS that ask the window for the focused element rather than act on the event. Name the Basic TableViewDelegate after the display role and ignore its Label, so a cell is read neither as empty text nor twice. This patch and its commit message were developed together with Claude Change-Id: Id42e994ad470abe8d4b0af4a7cc8e4c5611677e7 Reviewed-by: Richard Moe Gustavsen --- src/quick/CMakeLists.txt | 1 + src/quick/accessible/qaccessiblequicktableview.cpp | 32 ++++++++++++++ src/quick/accessible/qaccessiblequicktableview_p.h | 39 +++++++++++++++++ src/quick/accessible/qquickaccessiblefactory.cpp | 4 ++ src/quick/items/qquicktableview.cpp | 49 ++++++++++++++++++++++ src/quick/items/qquicktableview_p_p.h | 6 +++ src/quickcontrols/basic/TableViewDelegate.qml | 5 +++ 7 files changed, 136 insertions(+) create mode 100644 src/quick/accessible/qaccessiblequicktableview.cpp create mode 100644 src/quick/accessible/qaccessiblequicktableview_p.h diff --git a/src/quick/CMakeLists.txt b/src/quick/CMakeLists.txt index 64682edc84..c938da26a4 100644 --- a/src/quick/CMakeLists.txt +++ b/src/quick/CMakeLists.txt @@ -654,6 +654,7 @@ qt_internal_extend_target(Quick CONDITION QT_FEATURE_accessibility accessible/qaccessiblequickview.cpp accessible/qaccessiblequickview_p.h accessible/qaccessiblequickflickable.cpp accessible/qaccessiblequickflickable_p.h accessible/qaccessiblequicklistview.cpp accessible/qaccessiblequicklistview_p.h + accessible/qaccessiblequicktableview.cpp accessible/qaccessiblequicktableview_p.h accessible/qaccessiblequickwindowcontainer.cpp accessible/qaccessiblequickwindowcontainer_p.h accessible/qquickaccessiblefactory.cpp accessible/qquickaccessiblefactory_p.h LIBRARIES diff --git a/src/quick/accessible/qaccessiblequicktableview.cpp b/src/quick/accessible/qaccessiblequicktableview.cpp new file mode 100644 index 0000000000..63f9217b0f --- /dev/null +++ b/src/quick/accessible/qaccessiblequicktableview.cpp @@ -0,0 +1,32 @@ +// Copyright (C) 2026 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +// Qt-Security score:significant reason:default + +#include "qaccessiblequicktableview_p.h" + +#include + +QT_BEGIN_NAMESPACE + +#if QT_CONFIG(accessibility) + +QAccessibleQuickTableView::QAccessibleQuickTableView(QQuickTableView *tableView) + : QAccessibleQuickFlickable(tableView) +{ +} + +// The view holds the focus, but assistive technology follows the current cell. +// Platforms that ask for the focused element, rather than taking it from the +// focus event, find the delegate of that cell here. +QAccessibleInterface *QAccessibleQuickTableView::focusChild() const +{ + if (auto *view = qobject_cast(object())) { + if (QQuickItem *item = QQuickTableViewPrivate::get(view)->accessibleFocusItem) + return QAccessible::queryAccessibleInterface(item); + } + return nullptr; +} + +#endif // accessibility + +QT_END_NAMESPACE diff --git a/src/quick/accessible/qaccessiblequicktableview_p.h b/src/quick/accessible/qaccessiblequicktableview_p.h new file mode 100644 index 0000000000..7b26a986d2 --- /dev/null +++ b/src/quick/accessible/qaccessiblequicktableview_p.h @@ -0,0 +1,39 @@ +// Copyright (C) 2026 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +// Qt-Security score:significant reason:default + +#ifndef QACCESSIBLEQUICKTABLEVIEW_H +#define QACCESSIBLEQUICKTABLEVIEW_H + +// +// W A R N I N G +// ------------- +// +// This file is not part of the Qt API. It exists purely as an +// implementation detail. This header file may change from version to +// version without notice, or even be removed. +// +// We mean it. +// + +#include "qaccessiblequickflickable_p.h" + +QT_BEGIN_NAMESPACE + +#if QT_CONFIG(accessibility) + +class QQuickTableView; + +class Q_QUICK_EXPORT QAccessibleQuickTableView : public QAccessibleQuickFlickable +{ +public: + QAccessibleQuickTableView(QQuickTableView *tableView); + + QAccessibleInterface *focusChild() const override; +}; + +#endif // accessibility + +QT_END_NAMESPACE + +#endif // QACCESSIBLEQUICKTABLEVIEW_H diff --git a/src/quick/accessible/qquickaccessiblefactory.cpp b/src/quick/accessible/qquickaccessiblefactory.cpp index 3245398ce2..e053645d8c 100644 --- a/src/quick/accessible/qquickaccessiblefactory.cpp +++ b/src/quick/accessible/qquickaccessiblefactory.cpp @@ -10,10 +10,12 @@ #include "qaccessiblequicktextinput_p.h" #include "qaccessiblequickflickable_p.h" #include "qaccessiblequicklistview_p.h" +#include "qaccessiblequicktableview_p.h" #include "qaccessiblequickwindowcontainer_p.h" #include #include #include +#include #include #include #include @@ -31,6 +33,8 @@ QAccessibleInterface *qQuickAccessibleFactory(const QString &classname, QObject return new QAccessibleQuickTextInput(qobject_cast(object)); if (classname == QLatin1String("QQuickListView")) return new QAccessibleQuickListView(qobject_cast(object)); + if (classname == QLatin1String("QQuickTableView")) + return new QAccessibleQuickTableView(qobject_cast(object)); if (classname == QLatin1String("QQuickFlickable")) return new QAccessibleQuickFlickable(qobject_cast(object)); if (classname == QLatin1String("QQuickWindowContainer")) diff --git a/src/quick/items/qquicktableview.cpp b/src/quick/items/qquicktableview.cpp index db5da6f886..7e1a6809d4 100644 --- a/src/quick/items/qquicktableview.cpp +++ b/src/quick/items/qquicktableview.cpp @@ -20,6 +20,10 @@ #include #include +#if QT_CONFIG(accessibility) +#include +#endif + #include /*! @@ -3023,6 +3027,12 @@ void QQuickTableViewPrivate::releaseItem(FxTableItem *fxTableItem, QQmlTableInst // the item is owned by the QML context rather than the model (e.g ObjectModel etc). auto item = fxTableItem->item; +#if QT_CONFIG(accessibility) + // A released item can come back for another cell, which needs a new focus event. + if (item == accessibleFocusItem) + accessibleFocusItem = nullptr; +#endif + if (fxTableItem->ownItem) { Q_TABLEVIEW_ASSERT(item, fxTableItem->index); delete item; @@ -3670,6 +3680,9 @@ void QQuickTableViewPrivate::processLoadRequest() syncLoadedTableFromLoadRequest(); layoutTableEdgeFromLoadRequest(); syncLoadedTableRectFromLoadedTable(); +#if QT_CONFIG(accessibility) + updateAccessibleFocus(); +#endif if (rebuildState == RebuildState::Done) { // Loading of this edge was not done as a part of a rebuild, but @@ -4614,6 +4627,9 @@ void QQuickTableViewPrivate::currentChangedInSelectionModel(const QModelIndex &c updateCurrentRowAndColumn(); setCurrentOnDelegateItem(previous, false); setCurrentOnDelegateItem(current, true); +#if QT_CONFIG(accessibility) + updateAccessibleFocus(); +#endif } void QQuickTableViewPrivate::updateCurrentRowAndColumn() @@ -4644,6 +4660,31 @@ void QQuickTableViewPrivate::setCurrentOnDelegateItem(const QModelIndex &index, setRequiredProperty(kRequiredProperty_current, QVariant::fromValue(isCurrent), cellIndex, item, false); } +#if QT_CONFIG(accessibility) +// Like the widget item views, send a focus event for the current item while the +// view has focus. TableView is no accessible item itself, so without the event +// assistive technology does not follow the current cell. The delegate of the +// current cell can load after the current index changed, for instance when Tab +// scrolls it into view. +void QQuickTableViewPrivate::updateAccessibleFocus() +{ + Q_Q(QQuickTableView); + QQuickItem *item = nullptr; + if (QAccessible::isActive() && q->hasActiveFocus() && selectionModel) { + const int cellIndex = modelIndexToCellIndex(selectionModel->currentIndex()); + if (loadedItems.contains(cellIndex)) + item = loadedTableItem(cellAtModelIndex(cellIndex))->item; + } + if (item == accessibleFocusItem) + return; + accessibleFocusItem = item; + if (item && QQuickItemPrivate::get(item)->isAccessible) { + QAccessibleEvent event(item, QAccessible::Focus); + QAccessible::updateAccessibility(&event); + } +} +#endif + void QQuickTableViewPrivate::itemCreatedCallback(int modelIndex, QObject*) { if (blockItemCreatedCallback) @@ -5337,6 +5378,9 @@ void QQuickTableViewPrivate::init() q->setFlag(QQuickItem::ItemIsFocusScope); q->setActiveFocusOnTab(true); +#if QT_CONFIG(accessibility) + QObject::connect(q, &QQuickItem::activeFocusChanged, q, [this] { updateAccessibleFocus(); }); +#endif positionXAnimation.setTargetObject(q); positionXAnimation.setProperty(QStringLiteral("contentX")); @@ -7231,6 +7275,11 @@ void QQuickTableView::closeEditor() // have an editItem, if the model has changed (e.g been reset)! d->editIndex = QModelIndex(); } +#if QT_CONFIG(accessibility) + // Focus goes back to the view, which is no accessible item, so announce the current cell again. + d->accessibleFocusItem = nullptr; + d->updateAccessibleFocus(); +#endif } QQuickTableViewAttached *QQuickTableView::qmlAttachedProperties(QObject *obj) diff --git a/src/quick/items/qquicktableview_p_p.h b/src/quick/items/qquicktableview_p_p.h index cdc9069ed5..3462c9a5dd 100644 --- a/src/quick/items/qquicktableview_p_p.h +++ b/src/quick/items/qquicktableview_p_p.h @@ -467,6 +467,9 @@ public: int currentRow = -1; int currentColumn = -1; +#if QT_CONFIG(accessibility) + QPointer accessibleFocusItem; +#endif QHash explicitColumnWidths; QHash explicitRowHeights; @@ -667,6 +670,9 @@ public: void currentChangedInSelectionModel(const QModelIndex ¤t, const QModelIndex &previous); void setCurrentOnDelegateItem(const QModelIndex &index, bool isCurrent); void updateCurrentRowAndColumn(); +#if QT_CONFIG(accessibility) + void updateAccessibleFocus(); +#endif void fetchMoreData(); diff --git a/src/quickcontrols/basic/TableViewDelegate.qml b/src/quickcontrols/basic/TableViewDelegate.qml index 9735095c9b..412746619c 100644 --- a/src/quickcontrols/basic/TableViewDelegate.qml +++ b/src/quickcontrols/basic/TableViewDelegate.qml @@ -17,6 +17,10 @@ T.TableViewDelegate { highlighted: control.selected + // The content item shows the same text, and stays out of the accessibility + // tree, so that the cell is not read twice. + Accessible.name: control.model.display ?? "" + required property int column required property int row required property var model @@ -31,6 +35,7 @@ T.TableViewDelegate { } contentItem: Label { + Accessible.ignored: true clip: false text: control.model.display ?? "" elide: Text.ElideRight -- cgit v1.2.3