diff --git a/STEPS.md b/STEPS.md
index 4e4fddd2..f55029bc 100644
--- a/STEPS.md
+++ b/STEPS.md
@@ -526,6 +526,62 @@ Then the element "#content" should be centered in the viewport
+
+ @Then the element :selector should have keyboard focus
+
+
+Assert that the element has keyboard focus
+
+
+```gherkin
+Then the element "#edit-name" should have keyboard focus
+
+```
+
+
+
+
+ @Then the element :selector should not have keyboard focus
+
+
+Assert that the element does not have keyboard focus
+
+
+```gherkin
+Then the element "#edit-name" should not have keyboard focus
+
+```
+
+
+
+
+ @Then the element :selector should have a visible focus outline
+
+
+Assert that the element has a visible focus indicator
+
+
+```gherkin
+Then the element "#edit-name" should have a visible focus outline
+
+```
+
+
+
+
+ @Then the element :selector should not have a visible focus outline
+
+
+Assert that the element does not have a visible focus indicator
+
+
+```gherkin
+Then the element "#decorative-icon" should not have a visible focus outline
+
+```
+
+
+
@Then the element :selector should be displayed
diff --git a/src/ElementTrait.php b/src/ElementTrait.php
index f8558f43..e91ae456 100644
--- a/src/ElementTrait.php
+++ b/src/ElementTrait.php
@@ -379,6 +379,158 @@ public function elementFocus(string $selector): void {
$this->elementExecuteJs($selector, '{{ELEMENT}}.focus();');
}
+ /**
+ * Assert that the element has keyboard focus.
+ *
+ * Verifies that the element matched by the selector is the current
+ * `document.activeElement`. This is the canonical check for tab-order tests,
+ * skip-link behaviour, modal focus traps, autofocus, and focus-after-action
+ * flows.
+ *
+ * @code
+ * Then the element "#edit-name" should have keyboard focus
+ * @endcode
+ *
+ * @javascript
+ */
+ #[Then('the element :selector should have keyboard focus')]
+ public function elementAssertHasKeyboardFocus(string $selector): void {
+ $this->elementAssertKeyboardFocus($selector, FALSE);
+ }
+
+ /**
+ * Assert that the element does not have keyboard focus.
+ *
+ * @code
+ * Then the element "#edit-name" should not have keyboard focus
+ * @endcode
+ *
+ * @javascript
+ */
+ #[Then('the element :selector should not have keyboard focus')]
+ public function elementAssertNotHasKeyboardFocus(string $selector): void {
+ $this->elementAssertKeyboardFocus($selector, TRUE);
+ }
+
+ /**
+ * Assert that the element has a visible focus indicator.
+ *
+ * Verifies that the element renders a visible focus indicator via either
+ * a CSS outline (non-`none` outline-style with a width greater than 0) or
+ * a non-`none` box-shadow. Guards WCAG 2.4.7 (Focus Visible) and catches
+ * accidental `outline: none` regressions introduced by stylesheet changes.
+ *
+ * @code
+ * Then the element "#edit-name" should have a visible focus outline
+ * @endcode
+ *
+ * @javascript
+ */
+ #[Then('the element :selector should have a visible focus outline')]
+ public function elementAssertHasVisibleFocusOutline(string $selector): void {
+ $this->elementAssertVisibleFocusOutline($selector, FALSE);
+ }
+
+ /**
+ * Assert that the element does not have a visible focus indicator.
+ *
+ * @code
+ * Then the element "#decorative-icon" should not have a visible focus outline
+ * @endcode
+ *
+ * @javascript
+ */
+ #[Then('the element :selector should not have a visible focus outline')]
+ public function elementAssertNotHasVisibleFocusOutline(string $selector): void {
+ $this->elementAssertVisibleFocusOutline($selector, TRUE);
+ }
+
+ /**
+ * Assert keyboard focus state for an element.
+ *
+ * @param string $selector
+ * The CSS selector.
+ * @param bool $is_inverted
+ * Whether to assert that the element does not have keyboard focus.
+ *
+ * @throws \Behat\Mink\Exception\ExpectationException
+ */
+ protected function elementAssertKeyboardFocus(string $selector, bool $is_inverted): void {
+ $element = $this->getSession()->getPage()->find('css', $selector);
+
+ if (!$element) {
+ throw new ElementNotFoundException($this->getSession()->getDriver(), 'element', 'css', $selector);
+ }
+
+ $script = <<elementExecuteJs($selector, $script);
+
+ if (!$is_inverted) {
+ if ($result === '__OK__') {
+ return;
+ }
+
+ $message = $result === '__NONE__'
+ ? sprintf('Expected element "%s" to have keyboard focus, but no element is focused.', $selector)
+ : sprintf('Expected element "%s" to have keyboard focus, but focus is on: %s', $selector, $result);
+ throw new ExpectationException($message, $this->getSession()->getDriver());
+ }
+
+ if ($result === '__OK__') {
+ throw new ExpectationException(sprintf('Expected element "%s" to not have keyboard focus, but it does.', $selector), $this->getSession()->getDriver());
+ }
+ }
+
+ /**
+ * Assert visible focus indicator state for an element.
+ *
+ * An element is considered to have a visible focus indicator when either
+ * its computed outline has a non-`none` style with a width greater than 0,
+ * or its computed box-shadow is not `none`.
+ *
+ * @param string $selector
+ * The CSS selector.
+ * @param bool $is_inverted
+ * Whether to assert that the element does not have a visible focus
+ * indicator.
+ *
+ * @throws \Behat\Mink\Exception\ExpectationException
+ */
+ protected function elementAssertVisibleFocusOutline(string $selector, bool $is_inverted): void {
+ $element = $this->getSession()->getPage()->find('css', $selector);
+
+ if (!$element) {
+ throw new ElementNotFoundException($this->getSession()->getDriver(), 'element', 'css', $selector);
+ }
+
+ $script = <<elementExecuteJs($selector, $script);
+ [$outline_style, $outline_width, $box_shadow] = explode('|', $result, 3);
+
+ $has_outline = $outline_style !== 'none' && (float) $outline_width > 0;
+ $has_shadow = $box_shadow !== 'none' && $box_shadow !== '';
+ $is_visible = $has_outline || $has_shadow;
+
+ if (!$is_inverted && !$is_visible) {
+ throw new ExpectationException(sprintf('Expected element "%s" to have a visible focus outline, but outline-style is "%s", outline-width is "%s", box-shadow is "%s".', $selector, $outline_style, $outline_width, $box_shadow), $this->getSession()->getDriver());
+ }
+
+ if ($is_inverted && $is_visible) {
+ throw new ExpectationException(sprintf('Expected element "%s" to not have a visible focus outline, but outline-style is "%s", outline-width is "%s", box-shadow is "%s".', $selector, $outline_style, $outline_width, $box_shadow), $this->getSession()->getDriver());
+ }
+ }
+
/**
* Assert that element with specified CSS is visible on page.
*
diff --git a/tests/behat/features/element.feature b/tests/behat/features/element.feature
index 65e06973..42cead6f 100644
--- a/tests/behat/features/element.feature
+++ b/tests/behat/features/element.feature
@@ -497,6 +497,134 @@ Feature: Check that ElementTrait works
Element matching css "#nonexistent-element" not found.
"""
+ @javascript
+ Scenario: Assert "Then the element :selector should have keyboard focus" and its negative form work as expected
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ And I focus on the element "#focus-input"
+ Then the element "#focus-input" should have keyboard focus
+ And the element "#focus-button-outline" should not have keyboard focus
+
+ @javascript
+ Scenario: Assert "Then the element :selector should have a visible focus outline" passes for an element with a CSS outline
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#focus-button-outline" should have a visible focus outline
+ And the element "#focus-button-no-outline" should not have a visible focus outline
+
+ @javascript
+ Scenario: Assert "Then the element :selector should have a visible focus outline" passes for an element using box-shadow as the indicator
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#focus-button-shadow" should have a visible focus outline
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should have keyboard focus" fails when the element does not exist
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#nonexistent-element" should have keyboard focus
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Element matching css "#nonexistent-element" not found.
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should have keyboard focus" fails when a different element is focused
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ And I focus on the element "#focus-input"
+ Then the element "#focus-button-outline" should have keyboard focus
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Expected element "#focus-button-outline" to have keyboard focus, but focus is on:
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should have keyboard focus" fails when no element is focused
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#focus-input" should have keyboard focus
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Expected element "#focus-input" to have keyboard focus, but no element is focused.
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should not have keyboard focus" fails when the element is focused
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ And I focus on the element "#focus-input"
+ Then the element "#focus-input" should not have keyboard focus
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Expected element "#focus-input" to not have keyboard focus, but it does.
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should have a visible focus outline" fails when the element does not exist
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#nonexistent-element" should have a visible focus outline
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Element matching css "#nonexistent-element" not found.
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should have a visible focus outline" fails when the element has no visible indicator
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#focus-button-no-outline" should have a visible focus outline
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Expected element "#focus-button-no-outline" to have a visible focus outline, but outline-style is "none"
+ """
+
+ @trait:ElementTrait
+ Scenario: Assert "Then the element :selector should not have a visible focus outline" fails when the element has an outline
+ Given some behat configuration
+ And scenario steps tagged with "@javascript @phpserver":
+ """
+ Given I am an anonymous user
+ When I visit "/sites/default/files/elements.html"
+ Then the element "#focus-button-outline" should not have a visible focus outline
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ Expected element "#focus-button-outline" to not have a visible focus outline, but outline-style is "solid"
+ """
+
@javascript @phpserver
Scenario: Assert click on element works
Given I am on the phpserver test page
diff --git a/tests/behat/fixtures/elements.html b/tests/behat/fixtures/elements.html
index 818aa1c0..9a8b89eb 100644
--- a/tests/behat/fixtures/elements.html
+++ b/tests/behat/fixtures/elements.html
@@ -70,6 +70,9 @@