Skip to content

Commit

Permalink
feat: make disabled buttons accessible with feature flag (#7050)
Browse files Browse the repository at this point in the history
Co-authored-by: Sascha Ißbrücker <[email protected]>
  • Loading branch information
vursen and sissbruecker authored Jan 27, 2025
1 parent b9464d9 commit d4885c7
Show file tree
Hide file tree
Showing 3 changed files with 326 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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).
* <p>
* 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}:
*
* <pre>
* com.vaadin.experimental.accessibleDisabledButtons = true
* </pre>
*
* 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}
* <p>
* 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}:
*
* <pre>
* com.vaadin.experimental.accessibleDisabledButtons = true
* </pre>
*
* 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}
* <p>
* 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}:
*
* <pre>
* com.vaadin.experimental.accessibleDisabledButtons = true
* </pre>
*
* This feature flag will enable focus events and focus shortcuts for
* disabled buttons.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
@Override
public Registration addFocusListener(
ComponentEventListener<FocusEvent<Button>> listener) {
return getEventBus().addListener(FocusEvent.class,
(ComponentEventListener) listener, registration -> {
if (isFeatureFlagEnabled(
FeatureFlags.ACCESSIBLE_DISABLED_BUTTONS)) {
registration.setDisabledUpdateMode(
DisabledUpdateMode.ALWAYS);
}
});
}

/**
* {@inheritDoc}
* <p>
* 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}:
*
* <pre>
* com.vaadin.experimental.accessibleDisabledButtons = true
* </pre>
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
@Override
public Registration addBlurListener(
ComponentEventListener<BlurEvent<Button>> 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");
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<FeatureFlags> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <p>
* 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}:
*
* <pre>
* com.vaadin.experimental.accessibleDisabledButtons = true
* </pre>
*/
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
}

/**
* @inheritDoc
*/
Expand Down

0 comments on commit d4885c7

Please sign in to comment.