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 */