diff --git a/vaadin-button-flow-parent/vaadin-button-flow/src/main/java/com/vaadin/flow/component/button/Button.java b/vaadin-button-flow-parent/vaadin-button-flow/src/main/java/com/vaadin/flow/component/button/Button.java
index 426173051bb..6d37053ba03 100644
--- a/vaadin-button-flow-parent/vaadin-button-flow/src/main/java/com/vaadin/flow/component/button/Button.java
+++ b/vaadin-button-flow-parent/vaadin-button-flow/src/main/java/com/vaadin/flow/component/button/Button.java
@@ -20,6 +20,8 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import com.vaadin.experimental.Feature;
+import com.vaadin.experimental.FeatureFlags;
import com.vaadin.flow.component.ClickEvent;
import com.vaadin.flow.component.ClickNotifier;
import com.vaadin.flow.component.Component;
@@ -30,7 +32,11 @@
import com.vaadin.flow.component.HasSize;
import com.vaadin.flow.component.HasStyle;
import com.vaadin.flow.component.HasText;
+import com.vaadin.flow.component.Key;
+import com.vaadin.flow.component.KeyModifier;
+import com.vaadin.flow.component.ShortcutRegistration;
import com.vaadin.flow.component.Tag;
+import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.html.Image;
@@ -39,7 +45,9 @@
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.HasTooltip;
import com.vaadin.flow.component.shared.internal.DisableOnClickController;
+import com.vaadin.flow.dom.DisabledUpdateMode;
import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.shared.Registration;
/**
* The Button component allows users to perform actions. It comes in several
@@ -341,12 +349,111 @@ public boolean isDisableOnClick() {
return disableOnClickController.isDisableOnClick();
}
+ /**
+ * Sets the button explicitly disabled or enabled. When disabled, the button
+ * is rendered as "dimmed" and prevents all user interactions (mouse and
+ * keyboard).
+ *
+ * Since disabled buttons are not focusable and cannot react to hover events
+ * by default, it can cause accessibility issues by making them entirely
+ * invisible to assistive technologies, and prevents the use of Tooltips to
+ * explain why the action is not available. This can be addressed with the
+ * feature flag {@code accessibleDisabledButtons}, which makes disabled
+ * buttons focusable and hoverable, while preventing them from being
+ * triggered. To enable this feature flag, add the following line to
+ * {@code src/main/resources/vaadin-featureflags.properties}:
+ *
+ *
+ * com.vaadin.experimental.accessibleDisabledButtons = true
+ *
+ *
+ * This feature flag will also enable focus events and focus shortcuts for
+ * disabled buttons.
+ */
@Override
public void setEnabled(boolean enabled) {
Focusable.super.setEnabled(enabled);
disableOnClickController.onSetEnabled(enabled);
}
+ /**
+ * {@inheritDoc}
+ *
+ * By default, focus shortcuts are only active when the button is enabled.
+ * To make disabled buttons also focusable, enable the following feature
+ * flag in {@code src/main/resources/vaadin-featureflags.properties}:
+ *
+ *
+ * com.vaadin.experimental.accessibleDisabledButtons = true
+ *
+ *
+ * This feature flag will enable focus events and focus shortcuts for
+ * disabled buttons.
+ */
+ @Override
+ public ShortcutRegistration addFocusShortcut(Key key,
+ KeyModifier... keyModifiers) {
+ ShortcutRegistration registration = Focusable.super.addFocusShortcut(
+ key, keyModifiers);
+ if (isFeatureFlagEnabled(FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS)) {
+ registration.setDisabledUpdateMode(DisabledUpdateMode.ALWAYS);
+ }
+ return registration;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * By default, buttons are only focusable in the enabled state. To make
+ * disabled buttons also focusable, enable the following feature flag in
+ * {@code src/main/resources/vaadin-featureflags.properties}:
+ *
+ *
+ * com.vaadin.experimental.accessibleDisabledButtons = true
+ *
+ *
+ * This feature flag will enable focus events and focus shortcuts for
+ * disabled buttons.
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public Registration addFocusListener(
+ ComponentEventListener> listener) {
+ return getEventBus().addListener(FocusEvent.class,
+ (ComponentEventListener) listener, registration -> {
+ if (isFeatureFlagEnabled(
+ FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS)) {
+ registration.setDisabledUpdateMode(
+ DisabledUpdateMode.ALWAYS);
+ }
+ });
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * By default, buttons are only focusable in the enabled state. To make
+ * disabled buttons also focusable, enable the following feature flag in
+ * {@code src/main/resources/vaadin-featureflags.properties}:
+ *
+ *
+ * com.vaadin.experimental.accessibleDisabledButtons = true
+ *
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public Registration addBlurListener(
+ ComponentEventListener> listener) {
+ return getEventBus().addListener(BlurEvent.class,
+ (ComponentEventListener) listener, registration -> {
+ if (isFeatureFlagEnabled(
+ FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS)) {
+ registration.setDisabledUpdateMode(
+ DisabledUpdateMode.ALWAYS);
+ }
+ });
+ }
+
private void updateIconSlot() {
iconComponent.getElement().setAttribute("slot",
iconAfterText ? "suffix" : "prefix");
@@ -410,4 +517,22 @@ private void updateThemeAttribute() {
getThemeNames().remove("icon");
}
}
+
+ /**
+ * Checks whether the given feature flag is active.
+ *
+ * @param feature
+ * the feature flag to check
+ * @return {@code true} if the feature flag is active, {@code false}
+ * otherwise
+ */
+ private boolean isFeatureFlagEnabled(Feature feature) {
+ UI ui = UI.getCurrent();
+ if (ui == null) {
+ return false;
+ }
+
+ return FeatureFlags.get(ui.getSession().getService().getContext())
+ .isEnabled(feature);
+ }
}
diff --git a/vaadin-button-flow-parent/vaadin-button-flow/src/test/java/com/vaadin/flow/component/button/tests/AccessibleDisabledButtonTest.java b/vaadin-button-flow-parent/vaadin-button-flow/src/test/java/com/vaadin/flow/component/button/tests/AccessibleDisabledButtonTest.java
new file mode 100644
index 00000000000..f638c6d0f7e
--- /dev/null
+++ b/vaadin-button-flow-parent/vaadin-button-flow/src/test/java/com/vaadin/flow/component/button/tests/AccessibleDisabledButtonTest.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2000-2025 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.button.tests;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import com.vaadin.experimental.FeatureFlags;
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.ComponentEventListener;
+import com.vaadin.flow.component.ComponentUtil;
+import com.vaadin.flow.component.Key;
+import com.vaadin.flow.component.KeyDownEvent;
+import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.dom.DomEvent;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.internal.nodefeature.ElementListenerMap;
+import com.vaadin.flow.server.VaadinContext;
+import com.vaadin.flow.server.VaadinService;
+import com.vaadin.flow.server.VaadinSession;
+
+import elemental.json.Json;
+
+public class AccessibleDisabledButtonTest {
+
+ private MockedStatic mockFeatureFlagsStatic = Mockito
+ .mockStatic(FeatureFlags.class);
+
+ private FeatureFlags mockFeatureFlags = Mockito.mock(FeatureFlags.class);
+
+ private Button button = Mockito.spy(Button.class);
+
+ @SuppressWarnings("rawtypes")
+ private ComponentEventListener mockFocusListener = Mockito
+ .mock(ComponentEventListener.class);
+
+ @SuppressWarnings("rawtypes")
+ private ComponentEventListener mockBlurListener = Mockito
+ .mock(ComponentEventListener.class);
+
+ private UI ui = new UI();
+
+ @Before
+ public void setUp() {
+ VaadinSession mockSession = Mockito.mock(VaadinSession.class);
+ VaadinService mockService = Mockito.mock(VaadinService.class);
+ VaadinContext mockContext = Mockito.mock(VaadinContext.class);
+
+ Mockito.when(mockSession.getService()).thenReturn(mockService);
+ Mockito.when(mockService.getContext()).thenReturn(mockContext);
+ mockFeatureFlagsStatic.when(() -> FeatureFlags.get(mockContext))
+ .thenReturn(mockFeatureFlags);
+
+ ui.getInternals().setSession(mockSession);
+ UI.setCurrent(ui);
+
+ button.setEnabled(false);
+ }
+
+ @After
+ public void tearDown() {
+ mockFeatureFlagsStatic.close();
+ UI.setCurrent(null);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void accessibleButtonsDisabled_focusListenerDisabled() {
+ button.addFocusListener(mockFocusListener);
+
+ fakeClientDomEvent(button, "focus");
+
+ Mockito.verify(mockFocusListener, Mockito.never())
+ .onComponentEvent(Mockito.any());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void accessibleButtonsEnabled_focusListenerEnabled() {
+ Mockito.when(mockFeatureFlags
+ .isEnabled(FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS))
+ .thenReturn(true);
+
+ button.addFocusListener(mockFocusListener);
+
+ fakeClientDomEvent(button, "focus");
+
+ Mockito.verify(mockFocusListener, Mockito.times(1))
+ .onComponentEvent(Mockito.any());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void accessibleButtonsDisabled_blurListenerDisabled() {
+ button.addBlurListener(mockBlurListener);
+
+ fakeClientDomEvent(button, "blur");
+
+ Mockito.verify(mockBlurListener, Mockito.never())
+ .onComponentEvent(Mockito.any());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void accessibleButtonsEnabled_blurListenerEnabled() {
+ Mockito.when(mockFeatureFlags
+ .isEnabled(FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS))
+ .thenReturn(true);
+
+ button.addBlurListener(mockBlurListener);
+
+ fakeClientDomEvent(button, "blur");
+
+ Mockito.verify(mockBlurListener, Mockito.times(1))
+ .onComponentEvent(Mockito.any());
+ }
+
+ @Test
+ public void accessibleButtonsDisabled_focusShortcutDisabled() {
+ button.addFocusShortcut(Key.KEY_A);
+ ui.add(button);
+ ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
+
+ var keydownEvent = new KeyDownEvent(button, "A"); // actual key of the
+ // event doesn't
+ // matter with this
+ // test setup, as the
+ // filtering happens
+ // on the client side
+ ComponentUtil.fireEvent(ui, keydownEvent);
+
+ Mockito.verify(button, Mockito.never()).focus();
+ }
+
+ @Test
+ public void accessibleButtonsEnabled_focusShortcutEnabled() {
+ Mockito.when(mockFeatureFlags
+ .isEnabled(FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS))
+ .thenReturn(true);
+
+ button.addFocusShortcut(Key.KEY_A);
+ ui.add(button);
+ ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
+
+ var keydownEvent = new KeyDownEvent(button, "A"); // actual key of the
+ // event doesn't
+ // matter with this
+ // test setup, as the
+ // filtering happens
+ // on the client side
+ ComponentUtil.fireEvent(ui, keydownEvent);
+
+ Mockito.verify(button, Mockito.times(1)).focus();
+ }
+
+ private void fakeClientDomEvent(Component component, String eventName) {
+ Element element = component.getElement();
+ DomEvent event = new DomEvent(element, eventName, Json.createObject());
+ element.getNode().getFeature(ElementListenerMap.class).fireEvent(event);
+ }
+}
diff --git a/vaadin-menu-bar-flow-parent/vaadin-menu-bar-flow/src/main/java/com/vaadin/flow/component/menubar/MenuBarItem.java b/vaadin-menu-bar-flow-parent/vaadin-menu-bar-flow/src/main/java/com/vaadin/flow/component/menubar/MenuBarItem.java
index 04cae855cc0..1d115622139 100644
--- a/vaadin-menu-bar-flow-parent/vaadin-menu-bar-flow/src/main/java/com/vaadin/flow/component/menubar/MenuBarItem.java
+++ b/vaadin-menu-bar-flow-parent/vaadin-menu-bar-flow/src/main/java/com/vaadin/flow/component/menubar/MenuBarItem.java
@@ -36,6 +36,29 @@ protected MenuBarSubMenu createSubMenu() {
return new MenuBarSubMenu(this, contentReset);
}
+ /**
+ * Sets the menu item explicitly disabled or enabled. When disabled, the
+ * menu item is rendered as "dimmed" and prevents all user interactions
+ * (mouse and keyboard).
+ *
+ * Since disabled buttons (root-level items) are not focusable and cannot
+ * react to hover events by default, it can cause accessibility issues by
+ * making them entirely invisible to assistive technologies, and prevents
+ * the use of Tooltips to explain why the action is not available. This can
+ * be addressed with the feature flag {@code accessibleDisabledButtons},
+ * which makes disabled buttons focusable and hoverable, while preventing
+ * them from being triggered. To enable this feature flag, add the following
+ * line to {@code src/main/resources/vaadin-featureflags.properties}:
+ *
+ *
+ * com.vaadin.experimental.accessibleDisabledButtons = true
+ *
+ */
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ }
+
/**
* @inheritDoc
*/