From d67b3d1b869a996f67a2d6d4982fa61b60c2515a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 16:09:09 +0300 Subject: [PATCH 1/2] Follow Android's tab order in a ScrollView's clipped focus search ReactScrollView.focusSearch hands FOCUS_FORWARD and FOCUS_BACKWARD to the shadow-tree search whenever the platform's next view lies outside the scroll view. That search took anything below or to the right as the next view, which is not Android's order: ViewGroup.addFocusables sorts each parent's children into rows by top edge, then by start edge, and walks the tree depth first. When the last view in Android's order had another focusable below or to its right, Tab went back to that view and the two alternated instead of leaving the scroll view. FocusOrderingHelper now lists the focusable views in Android's tab order, following the mounting layer's view flattening, and forward and backward take the neighbour in that list, or nothing past either end. RNTester gains a keyboard focus order example for ScrollView. --- .../react/fabric/FabricUIManagerBinding.cpp | 7 + .../jni/react/fabric/FocusOrderingHelper.cpp | 259 +++++++++++++----- .../jni/react/fabric/FocusOrderingHelper.h | 15 + .../test/FocusOrderingHelperTabOrderTest.cpp | 234 ++++++++++++++++ .../examples/ScrollView/ScrollViewExample.js | 36 +++ 5 files changed, 475 insertions(+), 76 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp index d5c154f1e6f1..8fbebe71c932 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp @@ -300,6 +300,13 @@ jint FabricUIManagerBinding::findNextFocusableElement( return -1; } + if (focusDirection == FocusDirection::FocusForward || + focusDirection == FocusDirection::FocusBackward) { + nextNode = FocusOrderingHelper::findNextInTabOrder( + *parentShadowNode, focusedTag, focusDirection.value()); + return nextNode == nullptr ? -1 : nextNode->getTag(); + } + LayoutMetrics childLayoutMetrics = uimanager->getRelativeLayoutMetrics( *focusedShadowNode, parentShadowNode.get(), {.includeTransform = true}); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp index 1e4e04c49d9c..44e1da77d830 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -8,6 +8,9 @@ #include "FocusOrderingHelper.h" #include +#include +#include + namespace facebook::react { int majorAxisDistanceRaw( @@ -84,24 +87,11 @@ bool isCandidate(Rect source, Rect dest, FocusDirection focusDirection) { (source.origin.y + source.size.height) <= dest.origin.y) && ((source.origin.y + source.size.height) < (dest.origin.y + dest.size.height)); + // Forward and backward follow the tab order instead, see + // FocusOrderingHelper::findNextInTabOrder case FocusDirection::FocusForward: - return ((source.origin.y < dest.origin.y || - (source.origin.y + source.size.height) <= dest.origin.y) && - ((source.origin.y + source.size.height) < - (dest.origin.y + dest.size.height))) || - ((source.origin.x < dest.origin.x || - (source.origin.x + source.size.width) <= dest.origin.x) && - (source.origin.x + source.size.width) < - (dest.origin.x + dest.size.width)); case FocusDirection::FocusBackward: - return (((source.origin.y + source.size.height) > - (dest.origin.y + dest.size.height) || - source.origin.y >= (dest.origin.y + dest.size.height)) && - source.origin.y > dest.origin.y) || - (((source.origin.x + source.size.width) > - (dest.origin.x + dest.size.width) || - source.origin.x >= (dest.origin.x + dest.size.width)) && - source.origin.x > dest.origin.x); + return false; } } @@ -131,65 +121,148 @@ bool isBetterCandidate( minorAxisDistance(focusDirection, source, current)); return candidateWeightedDistance < currentWeightedDistance; - /* - * For forward and backward, Android uses a different algorithm to tell the - * order in which to focus. It sorts the children of the root by top to - * bottom and then groups the views into "rows". Then, it looks for the - * index of the current focused, and focuses the next or previous element. - * - * We are not implementing this algorithm, but instead, we are using the - * same comparison logic android uses to sort the children to find the next - * focusable. - * - * See: - * https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/FocusFinder.java;l=833;drc=61197364367c9e404c7da6900658f1b16c42d0da - */ - case FocusDirection::FocusForward: { - Float currentRowBottom = source.origin.y + source.size.height; - Float candidateRowBottom = candidate.origin.y + candidate.size.height; - Float result = NAN; - - if (candidate.origin.y < currentRowBottom && - candidateRowBottom > source.origin.y) { - result = candidate.origin.x - current.origin.x; - if (result == 0.0) { - result = (candidate.origin.x + candidate.size.width) - - (current.origin.x + current.size.width); - } - return result < 0.0; - } else { - result = candidate.origin.y - current.origin.y; - if (result == 0.0) { - result = (candidate.origin.y + candidate.size.height) - - (current.origin.y + current.size.height); - } - return result < 0.0; - } + case FocusDirection::FocusForward: + case FocusDirection::FocusBackward: + return false; + } + return false; +} + +bool isKeyboardFocusable(const ShadowNode& shadowNode) { + const auto* props = + dynamic_cast(shadowNode.getProps().get()); + + return shadowNode.getTraits().check( + ShadowNodeTraits::Trait::KeyboardFocusable) || + (props != nullptr && (props->focusable || props->accessible)); +} + +struct NativeChild { + std::shared_ptr shadowNode; + // In the coordinate space of the native parent + Rect frame; + bool hostsItsChildren; +}; + +// The views a native parent hosts, as the mounting layer flattens them: a +// child that does not form a view is skipped, and the children of one that +// does not form a stacking context are hoisted into the parent. +void appendNativeChildren( + const ShadowNode& parentShadowNode, + Point parentOffset, + std::vector& nativeChildren) { + bool childrenFormStackingContexts = parentShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ChildrenFormStackingContext); + + for (const auto& childShadowNode : parentShadowNode.getChildren()) { + const auto& traits = childShadowNode->getTraits(); + const auto* layoutableShadowNode = + dynamic_cast(childShadowNode.get()); + + if (traits.check(ShadowNodeTraits::Trait::RootNodeKind) || + traits.check(ShadowNodeTraits::Trait::Hidden) || + layoutableShadowNode == nullptr) { + continue; } - case FocusDirection::FocusBackward: { - Float currentRowBottom = source.origin.y + source.size.height; - Float candidateRowBottom = candidate.origin.y + candidate.size.height; - Float result = NAN; - - if (candidate.origin.y < currentRowBottom && - candidateRowBottom > source.origin.y) { - result = current.origin.x - candidate.origin.x; - if (result == 0.0) { - result = (current.origin.x + current.size.width) - - (candidate.origin.x + candidate.size.width); - } - return result < 0.0; - } else { - result = current.origin.y - candidate.origin.y; - if (result == 0.0) { - result = (current.origin.y + current.size.height) - - (candidate.origin.y + candidate.size.height); + + Rect frame = layoutableShadowNode->getLayoutMetrics().frame; + frame.origin += parentOffset; + + bool forceFlattenView = + traits.check(ShadowNodeTraits::Trait::ForceFlattenView); + bool isConcreteView = (traits.check(ShadowNodeTraits::Trait::FormsView) || + childrenFormStackingContexts) && + !forceFlattenView; + bool areChildrenFlattened = + (!traits.check(ShadowNodeTraits::Trait::FormsStackingContext) && + !childrenFormStackingContexts) || + forceFlattenView; + + if (isConcreteView) { + nativeChildren.push_back( + {.shadowNode = childShadowNode, + .frame = frame, + .hostsItsChildren = !areChildrenFlattened}); + } + + if (areChildrenFlattened) { + appendNativeChildren(*childShadowNode, frame.origin, nativeChildren); + } + } +} + +/* + * Sorts siblings the way Android's FocusFinder.FocusSorter does: top to bottom, + * then each run of vertically overlapping views forms a row, sorted by its + * start edge. + * + * See: + * https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/FocusFinder.java;l=833;drc=61197364367c9e404c7da6900658f1b16c42d0da + */ +void sortInTabOrder(std::vector& nativeChildren, bool isRTL) { + if (nativeChildren.size() < 2) { + return; + } + + auto sortRow = [isRTL](auto rowBegin, auto rowEnd) { + std::stable_sort( + rowBegin, rowEnd, [isRTL](const auto& first, const auto& second) { + if (first.frame.getMinX() != second.frame.getMinX()) { + return isRTL ? first.frame.getMinX() > second.frame.getMinX() + : first.frame.getMinX() < second.frame.getMinX(); + } + return first.frame.getMaxX() < second.frame.getMaxX(); + }); + }; + + std::stable_sort( + nativeChildren.begin(), + nativeChildren.end(), + [](const auto& first, const auto& second) { + if (first.frame.getMinY() != second.frame.getMinY()) { + return first.frame.getMinY() < second.frame.getMinY(); } - return result < 0.0; - } + return first.frame.getMaxY() < second.frame.getMaxY(); + }); + + auto rowBegin = nativeChildren.begin(); + Float sweepBottom = rowBegin->frame.getMaxY(); + for (auto it = std::next(nativeChildren.begin()); it != nativeChildren.end(); + ++it) { + if (it->frame.getMinY() >= sweepBottom) { + sortRow(rowBegin, it); + rowBegin = it; + sweepBottom = it->frame.getMaxY(); + } else { + sweepBottom = std::max(sweepBottom, it->frame.getMaxY()); + } + } + sortRow(rowBegin, nativeChildren.end()); +} + +bool isLayoutRTL(const ShadowNode& shadowNode) { + const auto* layoutableShadowNode = + dynamic_cast(&shadowNode); + return layoutableShadowNode != nullptr && + layoutableShadowNode->getLayoutMetrics().layoutDirection == + LayoutDirection::RightToLeft; +} + +void appendFocusablesInTabOrder( + const ShadowNode& parentShadowNode, + std::vector>& focusables) { + std::vector nativeChildren; + appendNativeChildren(parentShadowNode, Point{}, nativeChildren); + sortInTabOrder(nativeChildren, isLayoutRTL(parentShadowNode)); + + for (const auto& nativeChild : nativeChildren) { + if (isKeyboardFocusable(*nativeChild.shadowNode)) { + focusables.push_back(nativeChild.shadowNode); + } + if (nativeChild.hostsItsChildren) { + appendFocusablesInTabOrder(*nativeChild.shadowNode, focusables); } } - return false; } void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( @@ -201,13 +274,9 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( Rect sourceRect, std::optional& nextRect, std::shared_ptr& nextNode) { - const auto* props = - dynamic_cast(currNode->getProps().get()); - // We only care about focusable elements since only they can be both // focused and present in the hierarchy - if (currNode->getTraits().check(ShadowNodeTraits::Trait::KeyboardFocusable) || - (props != nullptr && (props->focusable || props->accessible))) { + if (isKeyboardFocusable(*currNode)) { LayoutMetrics nodeLayoutMetrics = uimanager.getRelativeLayoutMetrics( *currNode, parentShadowNode.get(), {.includeTransform = true}); @@ -244,6 +313,44 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( }; }; +std::vector> +FocusOrderingHelper::collectFocusablesInTabOrder( + const ShadowNode& rootShadowNode) { + std::vector> focusables; + appendFocusablesInTabOrder(rootShadowNode, focusables); + return focusables; +} + +std::shared_ptr FocusOrderingHelper::findNextInTabOrder( + const ShadowNode& rootShadowNode, + Tag focusedTag, + FocusDirection focusDirection) { + auto focusables = collectFocusablesInTabOrder(rootShadowNode); + auto focused = std::find_if( + focusables.begin(), focusables.end(), [focusedTag](const auto& node) { + return node->getTag() == focusedTag; + }); + + if (focused == focusables.end()) { + return nullptr; + } + + switch (focusDirection) { + case FocusDirection::FocusForward: + return std::next(focused) == focusables.end() ? nullptr + : *std::next(focused); + case FocusDirection::FocusBackward: + return focused == focusables.begin() ? nullptr : *std::prev(focused); + case FocusDirection::FocusDown: + case FocusDirection::FocusUp: + case FocusDirection::FocusRight: + case FocusDirection::FocusLeft: + return nullptr; + } + + return nullptr; +} + std::shared_ptr FocusOrderingHelper::findShadowNodeByTagRecursively( const std::shared_ptr& parentShadowNode, diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h index 802ba1699785..2c857faf2bc6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h @@ -31,6 +31,21 @@ class FocusOrderingHelper { std::optional &nextRect, std::shared_ptr &nextNode); + /* + * The focusable descendants of `rootShadowNode` in the order Android's + * FocusFinder gives FOCUS_FORWARD: each native parent sorts the views it + * hosts into rows by top edge, then by start edge, and lists itself before + * them. + */ + static std::vector> collectFocusablesInTabOrder(const ShadowNode &rootShadowNode); + + /* + * The focusable after (FocusForward) or before (FocusBackward) the one + * tagged `focusedTag` in that order, or nullptr past either end. + */ + static std::shared_ptr + findNextInTabOrder(const ShadowNode &rootShadowNode, Tag focusedTag, FocusDirection focusDirection); + static std::shared_ptr findShadowNodeByTagRecursively( const std::shared_ptr &parentShadowNode, Tag tag); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp new file mode 100644 index 000000000000..88a7abf1e370 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp @@ -0,0 +1,234 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace facebook::react { + +namespace { + +enum class ViewKind { + // Forms no view, so the mounting layer hoists its children into its parent + Flattened, + // Hosts its children, as `collapsable={false}` does + Native, + // Takes keyboard focus and hosts its children, as a Pressable does + Focusable, +}; + +Element view( + Tag tag, + ViewKind kind, + Rect frame, + LayoutDirection layoutDirection = LayoutDirection::LeftToRight) { + return Element() + .tag(tag) + .props([kind] { + auto props = std::make_shared(); + props->collapsable = kind == ViewKind::Flattened; + props->accessible = kind == ViewKind::Focusable; + return props; + }) + .finalize([frame, layoutDirection](ViewShadowNode& shadowNode) { + auto layoutMetrics = EmptyLayoutMetrics; + layoutMetrics.frame = frame; + layoutMetrics.layoutDirection = layoutDirection; + shadowNode.setLayoutMetrics(layoutMetrics); + }); +} + +Rect frame(Float x, Float y, Float width, Float height) { + return Rect{ + .origin = {.x = x, .y = y}, .size = {.width = width, .height = height}}; +} + +std::vector tabOrderTags(const ShadowNode& rootShadowNode) { + std::vector tags; + for (const auto& focusable : + FocusOrderingHelper::collectFocusablesInTabOrder(rootShadowNode)) { + tags.push_back(focusable->getTag()); + } + return tags; +} + +std::optional nextTagInTabOrder( + const ShadowNode& rootShadowNode, + Tag focusedTag, + FocusDirection focusDirection) { + auto next = FocusOrderingHelper::findNextInTabOrder( + rootShadowNode, focusedTag, focusDirection); + return next == nullptr ? std::nullopt : std::optional(next->getTag()); +} + +} // namespace + +TEST(FocusOrderingHelperTabOrderTest, testRowsGoTopToBottomThenByLeftEdge) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(200, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + view(4, ViewKind::Focusable, frame(0, 0, 80, 40)), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 2, 3}), tabOrderTags(*root)); +} + +TEST(FocusOrderingHelperTabOrderTest, testRightToLeftRowStartsAtTheRightEdge) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300), LayoutDirection::RightToLeft).children({ + view(2, ViewKind::Focusable, frame(200, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + view(4, ViewKind::Focusable, frame(0, 0, 80, 40)), + })); + // clang-format on + + EXPECT_EQ(std::vector({2, 4, 3}), tabOrderTags(*root)); +} + +TEST(FocusOrderingHelperTabOrderTest, testRowGrowsWhileViewsOverlapIt) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 0, 50, 100)), + view(3, ViewKind::Focusable, frame(100, 60, 50, 20)), + view(4, ViewKind::Focusable, frame(60, 90, 30, 30)), + })); + // clang-format on + + EXPECT_EQ(std::vector({2, 4, 3}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testViewThatHostsItsChildrenIsOrderedAsOneBlock) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Native, frame(0, 0, 100, 100)).children({ + view(3, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(4, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + view(5, ViewKind::Native, frame(120, 0, 100, 100)).children({ + view(6, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(7, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({3, 4, 6, 7}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testFlattenedViewLeavesItsChildrenToItsParentsRows) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Flattened, frame(0, 0, 100, 100)).children({ + view(3, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(4, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + view(5, ViewKind::Flattened, frame(120, 0, 100, 100)).children({ + view(6, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(7, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({3, 6, 4, 7}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testFocusableViewComesBeforeTheViewsItHosts) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 100, 300, 100)).children({ + view(3, ViewKind::Focusable, frame(10, 10, 50, 30)), + }), + view(4, ViewKind::Focusable, frame(0, 0, 300, 50)), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 2, 3}), tabOrderTags(*root)); +} + +/* + * A card whose content column holds an action below its title, with a dismiss + * button at the top end: Android puts the action first, so Tab from the + * dismiss button leaves the card instead of returning to the action below it. + */ +TEST( + FocusOrderingHelperTabOrderTest, + testTabFromTheLastFocusableLeavesInsteadOfReturningToOneBelowIt) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Native, frame(0, 0, 300, 100)).children({ + view(3, ViewKind::Native, frame(0, 0, 240, 100)).children({ + view(4, ViewKind::Focusable, frame(10, 60, 100, 30)), + }), + view(5, ViewKind::Focusable, frame(260, 10, 30, 30)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 5}), tabOrderTags(*root)); + EXPECT_EQ( + std::optional(5), + nextTagInTabOrder(*root, 4, FocusDirection::FocusForward)); + EXPECT_EQ( + std::optional(4), + nextTagInTabOrder(*root, 5, FocusDirection::FocusBackward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 5, FocusDirection::FocusForward)); +} + +TEST(FocusOrderingHelperTabOrderTest, testNextInTabOrderStopsAtEitherEnd) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + })); + // clang-format on + + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 2, FocusDirection::FocusBackward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 3, FocusDirection::FocusForward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 9, FocusDirection::FocusForward)); +} + +} // namespace facebook::react diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index ef937f99089c..5317ba2c88f5 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -554,6 +554,15 @@ if (Platform.OS === 'ios') { return ; }, }); + examples.push({ + name: 'keyboardFocusOrder', + title: ' Keyboard focus order\n', + description: + 'Tab through the card with a hardware keyboard: after its last control, focus should leave the scroll view for the button below it.', + render(): React.Node { + return ; + }, + }); } exports.examples = examples; @@ -611,6 +620,25 @@ const ScrollsChildToFocusExample = () => { ); }; +const KeyboardFocusOrderExample = () => { + return ( + + + + + A card with an action below its title +