From 3aaf65fb0642602dd1fe0f862af15b191411ba46 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Tue, 6 Oct 2026 12:24:40 -0300 Subject: [PATCH 1/2] build!: require Vaadin 25.3 and Java 21 --- .github/workflows/maven.yml | 20 ++----- pom.xml | 52 ++++++------------- .../frontend/fc-toggle-button.js | 0 3 files changed, 19 insertions(+), 53 deletions(-) rename src/main/resources/META-INF/{resources => }/frontend/fc-toggle-button.js (100%) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 91faec8..0e9f1f1 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,20 +16,7 @@ on: jobs: - build-vaadin24: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up JDK - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: maven - - name: Build (Vaadin 24) - run: mvn -B package --file pom.xml - - build-vaadin25: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -39,6 +26,5 @@ jobs: java-version: '21' distribution: 'temurin' cache: maven - - name: Build (Vaadin 25) - run: mvn -B package --file pom.xml -Pv25 - + - name: Build + run: mvn -B package --file pom.xml diff --git a/pom.xml b/pom.xml index 33a17bd..59248cc 100644 --- a/pom.xml +++ b/pom.xml @@ -4,16 +4,16 @@ com.flowingcode.vaadin.addons toggle-button-addon - 1.0.1-SNAPSHOT + 2.0.0-SNAPSHOT Toggle Button Add-On Toggle button built on Vaadin components with support for customizable labels and icons https://github.com/FlowingCode/ToggleButton - 24.9.1 + 25.3.0 4.10.0 - 17 - 17 + 21 + 21 UTF-8 UTF-8 ${project.basedir}/drivers @@ -97,10 +97,14 @@ test - javax.servlet - javax.servlet-api - 3.1.0 - jar + com.vaadin + vaadin-dev + true + + + jakarta.servlet + jakarta.servlet-api + 6.1.0 test @@ -200,6 +204,10 @@ com.vaadin vaadin-maven-plugin ${vaadin.version} + + + ${project.basedir}/src/main/resources/META-INF/frontend + @@ -514,12 +522,6 @@ - - - - - - @@ -572,28 +574,6 @@ - - - v25 - - 21 - 21 - 25.0.3 - - - - com.vaadin - vaadin-dev - true - - - jakarta.servlet - jakarta.servlet-api - 6.1.0 - test - - - diff --git a/src/main/resources/META-INF/resources/frontend/fc-toggle-button.js b/src/main/resources/META-INF/frontend/fc-toggle-button.js similarity index 100% rename from src/main/resources/META-INF/resources/frontend/fc-toggle-button.js rename to src/main/resources/META-INF/frontend/fc-toggle-button.js From c8c321aa64408aae952fdb48931ea4420a25f8d9 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Tue, 6 Oct 2026 12:24:48 -0300 Subject: [PATCH 2/2] feat!: rebuild on top of the core Switch Close #7 --- README.md | 79 ++- .../addons/togglebutton/ToggleButton.java | 392 +++++++++++-- .../togglebutton/ToggleButtonVariant.java | 17 +- .../META-INF/frontend/fc-toggle-button.js | 551 ++---------------- .../frontend/styles/fc-toggle-button.css | 250 ++++++++ .../togglebutton/ToggleButtonDemoView.java | 1 + .../togglebutton/ToggleButtonFormDemo.java | 139 +++++ .../ToggleButtonVariantsDemo.java | 29 +- .../togglebutton/it/AbstractViewTest.java | 27 + .../togglebutton/it/ToggleButtonIT.java | 56 +- .../it/ToggleButtonVariantsIT.java | 30 +- .../vaadin/addons/togglebutton/it/ViewIT.java | 13 +- .../togglebutton/test/AccessibleNameTest.java | 82 +++ .../togglebutton/test/BinderThemeTest.java | 80 +++ .../togglebutton/test/SignalBindingTest.java | 116 ++++ .../togglebutton/test/ToggleButtonTest.java | 129 ++++ 16 files changed, 1419 insertions(+), 572 deletions(-) create mode 100644 src/main/resources/META-INF/frontend/styles/fc-toggle-button.css create mode 100644 src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonFormDemo.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/AccessibleNameTest.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/BinderThemeTest.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/SignalBindingTest.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/ToggleButtonTest.java diff --git a/README.md b/README.md index 76a05d5..adeb2fe 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,31 @@ A toggle button component for Vaadin Flow that supports customizable labels and icons on both sides of the toggle. +Starting with version 2.0, the add-on is built on top of the core Vaadin [Switch](https://vaadin.com/docs/latest/components/switch) component. The switch itself (keyboard, focus, accessibility, read-only and disabled states) comes from Vaadin, and the add-on adds side labels, icons, label highlighting, theme variants and touch swipe around it. + ## Features -* Toggle between two states with a single click +* Toggle between two states with a click, a tap, a swipe, or the Space key * Customizable left and right labels -* Support for icons on both sides via slots +* Support for icons on both sides, set with `setLeftIcon` and `setRightIcon` * Optional label highlighting: active-side label uses the theme variant color (primary, success, warning, error, or contrast), inactive side is dimmed * Optional icons-inside layout: icons adjacent to the switch, labels on the outer edges * Theme variants: `SMALL`, `MEDIUM`, `LARGE`, `LONGSWIPE`, `PRIMARY`, `SUCCESS`, `WARNING`, `ERROR`, `CONTRAST` * `LONGSWIPE` variant produces a wider switch track, optimized for touch interaction; can be combined with size variants +* Accessible out of the box: announced as a switch with its on/off state, focusable, and operable with the keyboard +* Form support: helper text, required indicator, error message, and `Binder` validation +* Works with both the Lumo and Aura themes * Fluent API for easy configuration -* Full integration with Vaadin's `HasValue`, `HasSize`, `HasLabel`, `HasAriaLabel`, and `HasTooltip` +* Full integration with Vaadin's `HasValue`, `HasSize`, `HasLabel`, `HasAriaLabel`, `HasTooltip`, `HasHelper`, `HasValidationProperties`, and `Focusable` + +## Compatibility + +| Add-on version | Vaadin version | Java version | +|---|---|---| +| 2.x | 25.3 or newer | 21 | +| 1.x | 24, and 25.0 to 25.2 | 17 (Vaadin 24), 21 (Vaadin 25) | + +Version 2.x requires Vaadin 25.3 or newer, because it is built on the core `Switch` introduced in that release. Version 1.x is in maintenance mode for older Vaadin versions. ## Online demo @@ -145,8 +159,67 @@ toggle.addThemeVariants(ToggleButtonVariant.CONTRAST); // Long swipe: wider track, optimized for touch (can be combined with size variants) toggle.addThemeVariants(ToggleButtonVariant.LONGSWIPE); toggle.addThemeVariants(ToggleButtonVariant.LONGSWIPE, ToggleButtonVariant.LARGE); + +// Helper text, shown below the toggle and read by screen readers +ToggleButton toggle = new ToggleButton("Audit log retention", true); +toggle.setHelperText("Included on the Business plan."); +toggle.setReadOnly(true); ``` +### Forms and validation + +`ToggleButton` works with `Binder` like any other Vaadin field. A required toggle is valid when it is on: + +```java +ToggleButton confirm = new ToggleButton("I confirm the trip details are correct") + .setLeftLabel("No") + .setRightLabel("Yes"); + +binder.forField(confirm) + .asRequired("You must confirm the trip details to continue") + .bind(Booking::isConfirmed, Booking::setConfirmed); +``` + +The error message can also be set manually with `setErrorMessage(String)` and `setInvalid(boolean)`. + +### Accessibility + +The toggle is announced by screen readers as a switch with its on/off state. Its accessible name is taken from, in this order: + +1. The elements referenced with `setAriaLabelledBy(String)`, if set +2. The ARIA label, if set with `setAriaLabel(String)` +3. The field label +4. The side labels, read in order (for example, "Off On") + +The side labels and icons aren't read on their own, only as part of the name when there is no field label or ARIA label. When there is one and the side labels carry meaning that "on" and "off" don't convey (for example, "Dark" and "Light"), include it in the field label or the ARIA label. + +### Styling + +Color variants can be customized with these CSS custom properties on `fc-toggle-button`: `--toggle-button-primary-color`, `--toggle-button-success-color`, `--toggle-button-warning-color`, `--toggle-button-error-color`, and `--toggle-button-contrast-color`. + +The switch inside the component is a regular `vaadin-switch`, so it can also be styled with the core [Switch styling properties](https://vaadin.com/docs/latest/components/switch/styling), such as `--vaadin-switch-width` or `--vaadin-switch-background`: + +```css +fc-toggle-button vaadin-switch { + --vaadin-switch-width: 48px; +} +``` + +The rest of the component is rendered as regular elements that can be styled with these class names: `fc-toggle-button-label`, `fc-toggle-button-row`, `fc-toggle-button-left-label`, `fc-toggle-button-right-label`, `fc-toggle-button-left-icon`, `fc-toggle-button-right-icon`, `fc-toggle-button-helper`, and `fc-toggle-button-error`. + +## Migrating from 1.x + +Version 2.0 keeps the 1.x fluent API and value change events, but it has some breaking changes: + +* It requires Vaadin 25.3 or newer and Java 21. +* `ToggleButton` extends `AbstractField` instead of `AbstractSinglePropertyField`. Value change listeners keep working, and the event type is still `ComponentValueChangeEvent`. +* `ToggleButton` no longer implements `HasComponents`, so `add(...)` and `remove(...)` aren't available. Use `setLeftIcon(...)` and `setRightIcon(...)` for icons. +* Without a size variant, the toggle uses the native switch size of the active theme. Previously, the default was the same as `MEDIUM`. Add `ToggleButtonVariant.MEDIUM` to keep the 1.x size. +* The color variants write different values to the `theme` attribute: `color-primary`, `color-success`, `color-warning`, `color-error`, and `color-contrast` (previously `primary`, `success`, `warning`, `error`, and `contrast`). The Java constants in `ToggleButtonVariant` don't change. This only affects custom CSS that targets the attribute, such as `fc-toggle-button[theme~="error"]`. The old values collided with the theme names that `Binder` adds and removes for validation errors, so a toggle bound with `Binder` lost its `ERROR` or `WARNING` variant. +* Read-only and disabled states use the core Switch look. In read-only mode, the track border is solid instead of dashed. Color variants are kept as in 1.x: a checked read-only toggle shows a desaturated variant color on the track border, and a checked disabled toggle shows a faded variant color on the track. +* The tooltip is attached to the switch, so it opens when hovering or focusing the switch, not the side labels. +* When swiping, the marker no longer follows the finger. The toggle changes state when the swipe passes half the track width. + ## Special configuration when using Spring By default, Vaadin Flow only includes `com/vaadin/flow/component` to be always scanned for UI components and views. For this reason, the add-on might need to be allowed in order to display correctly. diff --git a/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButton.java b/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButton.java index 3c504ef..c9976ee 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButton.java +++ b/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButton.java @@ -19,39 +19,88 @@ */ package com.flowingcode.vaadin.addons.togglebutton; -import com.vaadin.flow.component.AbstractSinglePropertyField; +import com.vaadin.flow.component.AbstractField; +import com.vaadin.flow.component.BlurNotifier.BlurEvent; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.FocusNotifier.FocusEvent; +import com.vaadin.flow.component.FocusOption; +import com.vaadin.flow.component.Focusable; import com.vaadin.flow.component.HasAriaLabel; -import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.HasHelper; import com.vaadin.flow.component.HasLabel; import com.vaadin.flow.component.HasSize; import com.vaadin.flow.component.ItemLabelGenerator; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.checkbox.Switch; +import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.shared.HasThemeVariant; import com.vaadin.flow.component.shared.HasTooltip; +import com.vaadin.flow.component.shared.HasValidationProperties; import com.vaadin.flow.component.shared.Tooltip; +import com.vaadin.flow.dom.SignalBinding; +import com.vaadin.flow.shared.Registration; +import com.vaadin.flow.signals.Signal; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** - * A toggle button component built on Vaadin with support for customizable labels and icons. + * A toggle button component built on the core Vaadin {@link Switch}, with support for customizable + * labels and icons on both sides. * - *

The component displays a switch with optional left and right labels or icons, and fires a - * value-change event when toggled. + *

The switch itself (value, keyboard, focus, ARIA role and state, read-only and disabled + * behavior) is provided by an inner {@link Switch}. This component adds the field label, the side + * labels and icons, label highlighting, helper text, error message and theme variants around it. + * All of them are rendered as regular (light DOM) elements, so the switch can reference them for + * accessibility and they can be styled from the application. + * + *

The field label, helper text, error message and required indicator are implemented here on + * purpose, even though {@link Switch} has its own. The ones in {@link Switch} render next to the + * track, which would place them inside the row, between the side labels and icons. The inner + * switch is still marked as required (for {@code aria-required}) and invalid, and is linked to + * these elements with {@code aria-labelledby} and {@code aria-describedby}. * * @since 1.0.0 */ @Tag("fc-toggle-button") @JsModule("./fc-toggle-button.js") -public class ToggleButton extends AbstractSinglePropertyField +@CssImport("./styles/fc-toggle-button.css") +public class ToggleButton extends AbstractField implements HasSize, - HasComponents, HasLabel, HasAriaLabel, HasTooltip, + HasHelper, + HasValidationProperties, + Focusable, HasThemeVariant { - private ItemLabelGenerator itemLabelGenerator = b -> ""; - private Tooltip tooltip; + // Random, not a JVM counter: ids are serialized with the session and must stay unique after it + // is restored on another node or after a restart + private final String idPrefix = "fc-toggle-button-" + UUID.randomUUID(); + + private final Switch toggle = new Switch(); + private final Span label = part(new Span(), "label"); + private final Div row = part(new Div(), "row"); + private final Span leftLabel = part(new Span(), "left-label"); + private final Span rightLabel = part(new Span(), "right-label"); + private final Div helper = part(new Div(), "helper"); + private final Span error = part(new Span(), "error"); + + private Component leftIcon; + private Component rightIcon; + private Component helperComponent; + private Span boundHelperText; + private String labelText; + private String ariaLabel; + private String ariaLabelledBy; + private String errorMessage; /** Creates a new toggle button with an initial value of {@code false}. */ public ToggleButton() { @@ -65,11 +114,27 @@ public ToggleButton() { * @since 1.0.0 */ public ToggleButton(boolean initialValue) { - super("checked", initialValue, false); - if (initialValue) { - getElement().setAttribute("checked", ""); - } - setSynchronizedEvent("checked-changed"); + super(false); + // The wrapper owns validation; the inner switch only reflects the invalid state + toggle.setManualValidation(true); + toggle.addValueChangeListener(e -> { + if (e.isFromClient()) { + setModelValue(e.getValue(), true); + } + }); + + // Side labels are read through aria-labelledby when needed, not in reading order + leftLabel.getElement().setAttribute("aria-hidden", "true"); + rightLabel.getElement().setAttribute("aria-hidden", "true"); + // The error live region stays rendered (empty when valid) and only its text changes, so + // screen readers announce it; a region that appears with its content is often not announced + error.getElement().setAttribute("aria-live", "assertive"); + Stream.of(label, leftLabel, rightLabel, helper).forEach(c -> c.setVisible(false)); + + row.add(leftLabel, toggle, rightLabel); + getElement().appendChild(label.getElement(), row.getElement(), helper.getElement(), + error.getElement()); + setValue(initialValue); } /** @@ -95,31 +160,262 @@ public ToggleButton(String label, boolean initialValue) { setLabel(label); } + private C part(C component, String name) { + component.setId(idPrefix + "-" + name); + component.getElement().getClassList().add("fc-toggle-button-" + name); + return component; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + /** + * Sets the checked state of the toggle. + * + * @param value the new value; must not be {@code null} + * @throws NullPointerException if {@code value} is {@code null} + */ + @Override + public void setValue(Boolean value) { + super.setValue(Objects.requireNonNull(value, "Null value is not supported")); + } + + @Override + protected void setPresentationValue(Boolean value) { + toggle.setValue(value); + } + @Override public void setReadOnly(boolean readOnly) { - getElement().setProperty("readonly", readOnly); + super.setReadOnly(readOnly); + toggle.setReadOnly(readOnly); } @Override - public boolean isReadOnly() { - return getElement().getProperty("readonly", false); + public void setRequiredIndicatorVisible(boolean requiredIndicatorVisible) { + super.setRequiredIndicatorVisible(requiredIndicatorVisible); + // Sets aria-required on the native input; the inner indicator is hidden by CSS + toggle.setRequiredIndicatorVisible(requiredIndicatorVisible); } + // The inherited bind* defaults only bind a property of this element, so the inner switch, which + // is the one that blocks user changes and sets aria-required, is bound as well + @Override - public Tooltip setTooltipText(String text) { - if (tooltip == null) { - tooltip = Tooltip.forComponent(this); + public SignalBinding bindReadOnly(Signal readOnly) { + toggle.bindReadOnly(readOnly); + return super.bindReadOnly(readOnly); + } + + @Override + public SignalBinding bindRequiredIndicatorVisible(Signal requiredIndicatorVisible) { + toggle.bindRequiredIndicatorVisible(requiredIndicatorVisible); + return super.bindRequiredIndicatorVisible(requiredIndicatorVisible); + } + + @Override + public void setLabel(String label) { + labelText = label; + this.label.setText(label); + this.label.setVisible(!isBlank(label)); + updateAccessibleName(); + } + + @Override + public String getLabel() { + return labelText; + } + + @Override + public void setAriaLabel(String ariaLabel) { + this.ariaLabel = ariaLabel; + updateAccessibleName(); + } + + @Override + public Optional getAriaLabel() { + return Optional.ofNullable(ariaLabel); + } + + @Override + public void setAriaLabelledBy(String ariaLabelledBy) { + this.ariaLabelledBy = ariaLabelledBy; + updateAccessibleName(); + } + + @Override + public Optional getAriaLabelledBy() { + return Optional.ofNullable(ariaLabelledBy); + } + + /** + * The accessible name goes on the inner switch: the ARIA labelledby set by the caller, otherwise + * the ARIA label, otherwise the field label, otherwise the side labels. Labels are referenced by + * id, so they stay in sync. + */ + private void updateAccessibleName() { + if (!isBlank(ariaLabelledBy)) { + toggle.setAriaLabel(null); + toggle.setAriaLabelledBy(ariaLabelledBy); + return; + } + if (!isBlank(ariaLabel)) { + toggle.setAriaLabelledBy((String) null); + toggle.setAriaLabel(ariaLabel); + return; } - tooltip.setText(text); - return tooltip; + toggle.setAriaLabel(null); + toggle.setAriaLabelledBy(label.isVisible() ? visibleIds(label) : visibleIds(leftLabel, rightLabel)); + } + + /** + * Joins the ids of the given parts that are present and visible, for ARIA references. + * + * @return the space-separated ids, or {@code null} when there are none + */ + private static String visibleIds(Component... parts) { + String ids = Stream.of(parts) + .filter(c -> c != null && c.isVisible()) + .map(c -> c.getId().orElseThrow()) + .collect(Collectors.joining(" ")); + return ids.isEmpty() ? null : ids; + } + + @Override + public void focus() { + toggle.focus(); + } + + @Override + public void focus(FocusOption... options) { + toggle.focus(options); + } + + @Override + public void blur() { + toggle.blur(); + } + + // Focus lands on the inner switch's input, and focus/blur events don't bubble to this element + + @Override + public Registration addFocusListener(ComponentEventListener> listener) { + return toggle.addFocusListener(e -> listener.onComponentEvent(new FocusEvent<>(this, e.isFromClient()))); + } + + @Override + public Registration addBlurListener(ComponentEventListener> listener) { + return toggle.addBlurListener(e -> listener.onComponentEvent(new BlurEvent<>(this, e.isFromClient()))); + } + + @Override + public void setTabIndex(int tabIndex) { + toggle.setTabIndex(tabIndex); + } + + @Override + public int getTabIndex() { + return toggle.getTabIndex(); + } + + @Override + public Tooltip setTooltipText(String text) { + return toggle.setTooltipText(text); } @Override public Tooltip getTooltip() { - if (tooltip == null) { - tooltip = Tooltip.forComponent(this); + return toggle.getTooltip(); + } + + @Override + public void setHelperText(String helperText) { + setHelperComponent(isBlank(helperText) ? null : new Span(helperText)); + } + + @Override + public String getHelperText() { + return helperComponent instanceof Span span ? span.getText() : null; + } + + /** + * Binds the helper text to a signal. The helper is shown only while the text is not blank, and + * linked to the switch with {@code aria-describedby}. + * + * @param helperText the signal to bind the helper text to; must not be {@code null} + * @return the signal binding + */ + @Override + public SignalBinding bindHelperText(Signal helperText) { + Objects.requireNonNull(helperText, "Signal cannot be null"); + Span text = new Span(); + setHelperComponent(text); + boundHelperText = text; + SignalBinding binding = text.getElement().bindText(helperText); + binding.onChange(context -> updateHelper()); + updateHelper(); + return binding; + } + + @Override + public void setHelperComponent(Component component) { + helper.removeAll(); + helperComponent = component; + boundHelperText = null; + if (component != null) { + helper.add(component); } - return tooltip; + updateHelper(); + } + + private void updateHelper() { + // A helper text bound to a signal may be blank; any other helper component is always shown + helper.setVisible(helperComponent != null + && (helperComponent != boundHelperText || !isBlank(boundHelperText.getText()))); + updateDescribedBy(); + } + + @Override + public Component getHelperComponent() { + return helperComponent; + } + + @Override + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + updateError(); + } + + @Override + public String getErrorMessage() { + return errorMessage; + } + + @Override + public void setInvalid(boolean invalid) { + getElement().setProperty("invalid", invalid); + toggle.setInvalid(invalid); + updateError(); + } + + @Override + public boolean isInvalid() { + return getElement().getProperty("invalid", false); + } + + private boolean isErrorShown() { + return isInvalid() && !isBlank(errorMessage); + } + + private void updateError() { + error.setText(isErrorShown() ? errorMessage : ""); + updateDescribedBy(); + } + + /** Helper and error message are linked to the switch by id. */ + private void updateDescribedBy() { + toggle.setAriaDescribedBy(visibleIds(helper, isErrorShown() ? error : null)); } /** @@ -132,16 +428,12 @@ public Tooltip getTooltip() { * @since 1.0.0 */ public ToggleButton setItemLabelGenerator(ItemLabelGenerator itemLabelGenerator) { - this.itemLabelGenerator = itemLabelGenerator; - updateLabels(); + Objects.requireNonNull(itemLabelGenerator); + setLeftLabel(itemLabelGenerator.apply(false)); + setRightLabel(itemLabelGenerator.apply(true)); return this; } - private void updateLabels() { - getElement().setProperty("leftLabel", itemLabelGenerator.apply(false)); - getElement().setProperty("rightLabel", itemLabelGenerator.apply(true)); - } - /** * Enables label highlighting: the label on the active side is shown using the color of the * active theme variant ({@link ToggleButtonVariant#PRIMARY PRIMARY}, @@ -154,7 +446,7 @@ private void updateLabels() { * @since 1.0.0 */ public ToggleButton withHighlightLabel() { - getElement().setProperty("highlightLabel", true); + getElement().setAttribute("highlight-label", true); return this; } @@ -169,7 +461,7 @@ public ToggleButton withHighlightLabel() { * @since 1.0.0 */ public ToggleButton withIconsInside() { - getElement().setProperty("iconsInside", true); + getElement().setAttribute("icons-inside", true); return this; } @@ -181,7 +473,7 @@ public ToggleButton withIconsInside() { * @since 1.0.0 */ public ToggleButton withIconsOutside() { - getElement().setProperty("iconsInside", false); + getElement().setAttribute("icons-inside", false); return this; } @@ -193,7 +485,7 @@ public ToggleButton withIconsOutside() { * @since 1.0.0 */ public ToggleButton withoutHighlightLabel() { - getElement().setProperty("highlightLabel", false); + getElement().setAttribute("highlight-label", false); return this; } @@ -205,7 +497,9 @@ public ToggleButton withoutHighlightLabel() { * @since 1.0.0 */ public ToggleButton setLeftLabel(String label) { - getElement().setProperty("leftLabel", label); + leftLabel.setText(label); + leftLabel.setVisible(!isBlank(label)); + updateAccessibleName(); return this; } @@ -217,7 +511,9 @@ public ToggleButton setLeftLabel(String label) { * @since 1.0.0 */ public ToggleButton setRightLabel(String label) { - getElement().setProperty("rightLabel", label); + rightLabel.setText(label); + rightLabel.setVisible(!isBlank(label)); + updateAccessibleName(); return this; } @@ -229,7 +525,7 @@ public ToggleButton setRightLabel(String label) { * @since 1.0.0 */ public ToggleButton setLeftIcon(Component icon) { - setSlottedIcon(icon, "left"); + leftIcon = replaceIcon(leftIcon, icon, "left-icon"); return this; } @@ -241,15 +537,19 @@ public ToggleButton setLeftIcon(Component icon) { * @since 1.0.0 */ public ToggleButton setRightIcon(Component icon) { - setSlottedIcon(icon, "right"); + rightIcon = replaceIcon(rightIcon, icon, "right-icon"); return this; } - private void setSlottedIcon(Component icon, String slot) { - getElement().getChildren() - .filter(e -> slot.equals(e.getAttribute("slot"))) - .forEach(e -> e.removeFromParent()); - icon.getElement().setAttribute("slot", slot); - add(icon); + private Component replaceIcon(Component oldIcon, Component icon, String className) { + if (oldIcon != null) { + row.remove(oldIcon); + } + if (icon != null) { + icon.getElement().getClassList().add("fc-toggle-button-" + className); + icon.getElement().setAttribute("aria-hidden", "true"); + row.add(icon); + } + return icon; } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariant.java b/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariant.java index 37c54b1..53e407c 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariant.java @@ -32,26 +32,29 @@ public enum ToggleButtonVariant implements ThemeVariant { /** Renders the toggle at a smaller size (32x18 px track). */ SMALL("small"), - /** Renders the toggle at the default medium size (44x24 px track). */ + /** + * Renders the toggle at a medium size (44x24 px track). Without a size variant, the toggle uses + * the native switch size of the active theme. + */ MEDIUM("medium"), /** Renders the toggle at a larger size (56x32 px track). */ LARGE("large"), /** * Renders a wider switch track optimized for touch interaction. Can be combined with size * variants: the track width is increased by 28 px while preserving the height of the chosen - * size. + * size. Without a size variant, it uses the {@link #MEDIUM} size as the base. */ LONGSWIPE("longswipe"), /** Applies the primary color to the checked state. */ - PRIMARY("primary"), + PRIMARY("color-primary"), /** Applies the success color to the checked state. */ - SUCCESS("success"), + SUCCESS("color-success"), /** Applies the warning color to the checked state. */ - WARNING("warning"), + WARNING("color-warning"), /** Applies the error color to the checked state. */ - ERROR("error"), + ERROR("color-error"), /** Applies the contrast color to the checked state. */ - CONTRAST("contrast"); + CONTRAST("color-contrast"); private final String variant; diff --git a/src/main/resources/META-INF/frontend/fc-toggle-button.js b/src/main/resources/META-INF/frontend/fc-toggle-button.js index 180d72f..b02569c 100644 --- a/src/main/resources/META-INF/frontend/fc-toggle-button.js +++ b/src/main/resources/META-INF/frontend/fc-toggle-button.js @@ -17,529 +17,104 @@ * limitations under the License. * #L% */ -import { LitElement, html, css } from 'lit'; /** - * Custom Toggle Button component. - * Supports left and right labels, and is fully stylable. + * Client-side behavior for ``. + * + * The layout is rendered by the server as light DOM around a core ``, and styled + * by `styles/fc-toggle-button.css`. This element only adds what needs to run in the browser: + * toggling when the labels or icons are clicked, and toggling with a horizontal swipe. + * + * Toggling always goes through a click on the switch's native input, so read-only, disabled and + * the user `change` event behave exactly as when clicking the switch itself. */ -class ToggleButton extends LitElement { - static properties = { - checked: { type: Boolean, reflect: true }, - label: { type: String }, - leftLabel: { type: String }, - rightLabel: { type: String }, - disabled: { type: Boolean, reflect: true }, - readonly: { type: Boolean, reflect: true }, - highlightLabel: { type: Boolean, reflect: true }, - iconsInside: { type: Boolean } - }; - - static styles = css` - :host { - display: inline-flex; - flex-direction: column; - align-items: flex-start; - gap: var(--lumo-space-xs, var(--vaadin-gap-xs)); - font-family: var(--lumo-font-family, var(--aura-font-family)); - color: var(--lumo-body-text-color, var(--vaadin-text-color)); - cursor: pointer; - user-select: none; - transition: opacity 0.2s; - /* Color tokens: default to theme variables. Override these when using Base theme. */ - --toggle-button-primary-color: var(--lumo-primary-color, var(--aura-blue)); - --toggle-button-success-color: var(--lumo-success-color, var(--aura-green)); - --toggle-button-error-color: var(--lumo-error-color, var(--aura-red)); - --toggle-button-warning-color: var(--lumo-warning-color, var(--aura-yellow)); - --toggle-button-contrast-color: var(--lumo-contrast, var(--aura-neutral)); - --toggle-button-primary-text-color: var(--lumo-primary-text-color, var(--aura-blue-text)); - --toggle-button-success-text-color: var(--lumo-success-text-color, var(--aura-green-text)); - --toggle-button-error-text-color: var(--lumo-error-text-color, var(--aura-red-text)); - --toggle-button-warning-text-color: var(--lumo-warning-text-color, var(--aura-orange-text)); - } - - .field-label { - font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); - font-weight: 500; - color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); - line-height: 1; - } - - :host([disabled]) { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; - } - - :host([readonly]) { - cursor: default; - pointer-events: none; - } - - .container { - display: flex; - align-items: center; - gap: var(--lumo-space-s, var(--vaadin-gap-s)); - } - - .label { - font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); - font-weight: 500; - transition: color 0.3s; - } - - .label.active { - color: var(--toggle-button-primary-text-color); - } - - :host([theme~="success"]) .label.active { - color: var(--toggle-button-success-text-color); - } - - :host([theme~="error"]) .label.active { - color: var(--toggle-button-error-text-color); - } - - :host([theme~="warning"]) .label.active { - color: var(--toggle-button-warning-text-color); - } - - :host([theme~="contrast"]) .label.active { - color: var(--toggle-button-contrast-color); - } - - .label.inactive { - color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); - opacity: 0.7; - } - - .switch { - position: relative; - width: 44px; - height: 24px; - background-color: var(--lumo-contrast-20pct, #e0e0e0); - border-radius: 12px; - transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: inline-block; - } - - :host([checked]) .switch { - background-color: var(--toggle-button-primary-color); - } - - /* Theme Variants */ - :host([theme~="success"][checked]) .switch { - background-color: var(--toggle-button-success-color); - } - - :host([theme~="error"][checked]) .switch { - background-color: var(--toggle-button-error-color); - } - - :host([theme~="warning"][checked]) .switch { - background-color: var(--toggle-button-warning-color); - color: rgba(0, 0, 0, 0.85); - } - - :host([theme~="primary"][checked]) .switch { - background-color: var(--toggle-button-primary-color); - } - - :host([theme~="contrast"][checked]) .switch { - background-color: var(--toggle-button-contrast-color); - } - - :host([theme~="contrast"]) .slider { - background-color: var(--lumo-base-color, var(--vaadin-background-color, #ffffff)); - } - - /* Slider colors for variants if needed */ - :host([theme~="warning"]) .slider { - background-color: #fff; - } - - .slider { - position: absolute; - top: 3px; - left: 3px; - width: 18px; - height: 18px; - background-color: var(--lumo-base-color, var(--vaadin-background-color, #ffffff)); - border-radius: 50%; - box-shadow: var(--lumo-box-shadow-s, var(--aura-shadow-xs, 0 1px 2px rgba(0,0,0,0.2))); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - - :host([checked]) .slider { - transform: translateX(20px); - } - - /* Hover effects */ - :host(:hover:not([disabled])) .switch { - filter: brightness(0.95); - } - - :host([checked]:hover:not([disabled])) .switch { - filter: brightness(1.1); - } - - .switch:active .slider { - width: 22px; - } - - :host([checked]) .switch:active .slider { - transform: translateX(16px); - } - - :host([theme~="medium"]) .switch:active .slider { - width: 22px; - } - - :host([theme~="medium"][checked]) .switch:active .slider { - transform: translateX(16px); - } - - :host([theme~="small"]) .switch:active .slider { - width: 17px; - } - - :host([theme~="small"][checked]) .switch:active .slider { - transform: translateX(11px); - } - - :host([theme~="large"]) .switch:active .slider { - width: 30px; - } - - :host([theme~="large"][checked]) .switch:active .slider { - transform: translateX(18px); - } - - /* Size Variants */ - :host([theme~="medium"]) .switch { - width: 44px; - height: 24px; - } - - :host([theme~="medium"]) .slider { - width: 18px; - height: 18px; - top: 3px; - left: 3px; - } - - :host([theme~="medium"][checked]) .slider { - transform: translateX(20px); - } - - :host([theme~="medium"]) .label { - font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); - } - - :host([theme~="small"]) .switch { - width: 32px; - height: 18px; - } - - :host([theme~="small"]) .slider { - width: 14px; - height: 14px; - top: 2px; - left: 2px; - } - - :host([theme~="small"][checked]) .slider { - transform: translateX(14px); - } - - :host([theme~="small"]) .label { - font-size: var(--lumo-font-size-xs, var(--aura-font-size-xs)); - } - - :host([theme~="large"]) .switch { - width: 56px; - height: 32px; - border-radius: 16px; - } - - :host([theme~="large"]) .slider { - width: 24px; - height: 24px; - top: 4px; - left: 4px; - } - - :host([theme~="large"][checked]) .slider { - transform: translateX(24px); - } - - :host([theme~="large"]) .label { - font-size: var(--lumo-font-size-l, var(--aura-font-size-l)); - } - - :host([theme~="longswipe"]) .switch { - width: 72px; - height: 24px; - } - - :host([theme~="longswipe"]) .slider { - width: 18px; - height: 18px; - top: 3px; - left: 3px; - } - - :host([theme~="longswipe"][checked]) .slider { - transform: translateX(48px); - } - - :host([theme~="longswipe"]) .switch:active .slider { - width: 22px; - } - - :host([theme~="longswipe"][checked]) .switch:active .slider { - transform: translateX(44px); - } - - :host([theme~="longswipe"]) .label { - font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); - } - - :host([theme~="longswipe"][theme~="small"]) .switch { - width: 60px; - height: 18px; - } - - :host([theme~="longswipe"][theme~="small"]) .slider { - width: 14px; - height: 14px; - top: 2px; - left: 2px; - } - - :host([theme~="longswipe"][theme~="small"][checked]) .slider { - transform: translateX(42px); - } - - :host([theme~="longswipe"][theme~="small"]) .switch:active .slider { - width: 17px; - } - - :host([theme~="longswipe"][theme~="small"][checked]) .switch:active .slider { - transform: translateX(39px); - } - - :host([theme~="longswipe"][theme~="large"]) .switch { - width: 84px; - height: 32px; - border-radius: 16px; - } - - :host([theme~="longswipe"][theme~="large"]) .slider { - width: 24px; - height: 24px; - top: 4px; - left: 4px; - } - - :host([theme~="longswipe"][theme~="large"][checked]) .slider { - transform: translateX(52px); - } - - :host([theme~="longswipe"][theme~="large"]) .switch:active .slider { - width: 30px; - } - - :host([theme~="longswipe"][theme~="large"][checked]) .switch:active .slider { - transform: translateX(46px); - } - - /* Readonly Styles: Unify the look for both checked/unchecked and variants */ - :host([readonly]) .switch { - background-color: transparent; - border: 1px dashed var(--lumo-contrast-30pct, var(--vaadin-border-color)); - box-sizing: border-box; - } - - :host([readonly]) .slider { - top: 2px; - left: 2px; - box-shadow: none; - background-color: var(--lumo-contrast-40pct, var(--vaadin-background-container-strong)); - } - - :host([readonly][theme~="small"]) .slider { - top: 1px; - left: 1px; - } - - :host([readonly][theme~="large"]) .slider { - top: 3px; - left: 3px; - } - - /* Readonly + checked + color variant: desaturated variant tint on the border. */ - :host([theme~="primary"][checked][readonly]) .switch { - background-color: transparent; - border-color: color-mix(in srgb, var(--toggle-button-primary-color) 40%, var(--lumo-contrast-30pct, var(--vaadin-border-color))); - } - - :host([theme~="success"][checked][readonly]) .switch { - background-color: transparent; - border-color: color-mix(in srgb, var(--toggle-button-success-color) 40%, var(--lumo-contrast-30pct, var(--vaadin-border-color))); - } - - :host([theme~="error"][checked][readonly]) .switch { - background-color: transparent; - border-color: color-mix(in srgb, var(--toggle-button-error-color) 40%, var(--lumo-contrast-30pct, var(--vaadin-border-color))); - } - - :host([theme~="warning"][checked][readonly]) .switch { - background-color: transparent; - border-color: color-mix(in srgb, var(--toggle-button-warning-color) 40%, var(--lumo-contrast-30pct, var(--vaadin-border-color))); - } - - :host([theme~="contrast"][checked][readonly]) .switch { - background-color: transparent; - border-color: color-mix(in srgb, var(--toggle-button-contrast-color) 40%, var(--lumo-contrast-30pct, var(--vaadin-border-color))); - } - `; - +class ToggleButton extends HTMLElement { constructor() { super(); - this.checked = false; - this.label = ''; - this.leftLabel = ''; - this.rightLabel = ''; - this.disabled = false; - this.readonly = false; - this.highlightLabel = false; - this.iconsInside = false; this._touchStartX = null; this._touchStartY = null; this._isSwiping = false; - this._swipeHandled = false; - this.addEventListener('click', this._onClick.bind(this)); - this.addEventListener('touchstart', this._onTouchStart.bind(this), { passive: true }); - this.addEventListener('touchmove', this._onTouchMove.bind(this), { passive: false }); - this.addEventListener('touchend', this._onTouchEnd.bind(this)); + this._suppressClick = false; + + // Swallows the click a browser may synthesize after a swipe, so the switch doesn't toggle twice + this.addEventListener('click', (e) => { + if (this._suppressClick) { + this._suppressClick = false; + e.preventDefault(); + e.stopPropagation(); + } + }, true); + this.addEventListener('click', (e) => this._onClick(e)); + this.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: true }); + this.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false }); + this.addEventListener('touchend', (e) => this._onTouchEnd(e)); + } + + get _switch() { + return this.querySelector('vaadin-switch'); } - _fireChange() { - this.dispatchEvent(new CustomEvent('checked-changed', { - detail: { value: this.checked }, - bubbles: true, - composed: true - })); + _toggle() { + const sw = this._switch; + (sw?.inputElement || sw?.querySelector('input'))?.click(); } _onClick(e) { - if (this._swipeHandled) { - this._swipeHandled = false; - return; - } - if (this.disabled || this.readonly) return; - this.checked = !this.checked; - this._fireChange(); + // Only the field label and the row toggle: not blank space of the host, helper or error message + if (!e.target.closest('.fc-toggle-button-label, .fc-toggle-button-row')) return; + // The switch handles its own clicks + if (e.target.closest('vaadin-switch')) return; + // Interactive content passed as an icon or label (links, buttons, fields) keeps its own behavior + const interactive = e.target.closest( + 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="switch"]' + ); + if (interactive && this.contains(interactive)) return; + this._toggle(); } _onTouchStart(e) { - if (this.disabled || this.readonly) return; + // A new touch ends any click suppression left by a previous swipe + this._suppressClick = false; + const sw = this._switch; + if (!sw || sw.disabled || sw.readonly || !e.target.closest('.fc-toggle-button-row')) return; const touch = e.touches[0]; this._touchStartX = touch.clientX; this._touchStartY = touch.clientY; this._isSwiping = false; - this._swipeHandled = false; - - const slider = this.shadowRoot.querySelector('.slider'); - if (slider) { - slider.style.transition = 'none'; - } } _onTouchMove(e) { - if (this._touchStartX === null || this.disabled || this.readonly) return; + if (this._touchStartX === null) return; const touch = e.touches[0]; const dx = touch.clientX - this._touchStartX; const dy = touch.clientY - this._touchStartY; - // Only capture horizontal swipes - if (!this._isSwiping && Math.abs(dx) < Math.abs(dy)) return; + if (!this._isSwiping && (Math.abs(dx) < 10 || Math.abs(dx) < Math.abs(dy))) return; this._isSwiping = true; e.preventDefault(); - - const switchEl = this.shadowRoot.querySelector('.switch'); - const slider = this.shadowRoot.querySelector('.slider'); - if (!switchEl || !slider) return; - - const switchWidth = switchEl.offsetWidth; - const sliderWidth = slider.offsetWidth; - const gap = parseInt(getComputedStyle(slider).left); - const maxTranslate = switchWidth - sliderWidth - gap * 2; - const baseTranslate = this.checked ? maxTranslate : 0; - const clamped = Math.max(0, Math.min(maxTranslate, baseTranslate + dx)); - slider.style.transform = `translateX(${clamped}px)`; } _onTouchEnd(e) { - if (this._touchStartX === null || this.disabled || this.readonly) return; - const slider = this.shadowRoot.querySelector('.slider'); - const switchEl = this.shadowRoot.querySelector('.switch'); - - if (!this._isSwiping) { - if (slider) slider.style.transition = ''; - this._touchStartX = null; - this._touchStartY = null; - return; - } - - const touch = e.changedTouches[0]; - const dx = touch.clientX - this._touchStartX; - - // Threshold: 50% of the slider's actual travel range - const sliderWidth = slider ? slider.offsetWidth : 18; - const gap = slider ? parseInt(getComputedStyle(slider).left) : 3; - const maxTranslate = switchEl ? switchEl.offsetWidth - sliderWidth - gap * 2 : 20; - const threshold = maxTranslate * 0.5; - - const newChecked = dx > 0; - if (Math.abs(dx) >= threshold && newChecked !== this.checked) { - this.checked = newChecked; - this._fireChange(); - this._swipeHandled = true; - } - - // Re-enable transition, then on the next frame clear the inline transform so - // the CSS-driven position animates smoothly from the current mid-swipe position. - if (slider) slider.style.transition = ''; - requestAnimationFrame(() => { - if (slider) slider.style.transform = ''; - }); - + if (this._touchStartX === null) return; + const dx = e.changedTouches[0].clientX - this._touchStartX; + const wasSwiping = this._isSwiping; this._touchStartX = null; this._touchStartY = null; this._isSwiping = false; - } - - render() { - const leftLabelClass = this.highlightLabel && !this.checked ? 'active' : this.highlightLabel ? 'inactive' : ''; - const rightLabelClass = this.highlightLabel && this.checked ? 'active' : this.highlightLabel ? 'inactive' : ''; - const leftLabelEl = this.leftLabel ? html`${this.leftLabel}` : ''; - const rightLabelEl = this.rightLabel ? html`${this.rightLabel}` : ''; - const leftSlot = html``; - const rightSlot = html``; - return html` - ${this.label ? html`${this.label}` : ''} -

- ${this.iconsInside ? leftLabelEl : leftSlot} - ${this.iconsInside ? leftSlot : leftLabelEl} -
-
-
- ${this.iconsInside ? rightSlot : rightLabelEl} - ${this.iconsInside ? rightLabelEl : rightSlot} -
- `; + if (!wasSwiping) return; + + // Threshold: half of the track width. The switch mirrors its marker in RTL, so "on" is to the left + const sw = this._switch; + const threshold = (sw?.offsetWidth || 40) * 0.5; + const towardOn = getComputedStyle(sw).direction === 'rtl' ? dx < 0 : dx > 0; + if (Math.abs(dx) >= threshold && towardOn !== !!sw?.checked) { + this._toggle(); + } + // Cleared by the next touchstart, so a real tap right after the swipe still works + this._suppressClick = true; + setTimeout(() => { + this._suppressClick = false; + }, 400); } } diff --git a/src/main/resources/META-INF/frontend/styles/fc-toggle-button.css b/src/main/resources/META-INF/frontend/styles/fc-toggle-button.css new file mode 100644 index 0000000..e841797 --- /dev/null +++ b/src/main/resources/META-INF/frontend/styles/fc-toggle-button.css @@ -0,0 +1,250 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ + +/* + * Styles for and the core inside it. + * + * The component is rendered as light DOM, so everything here is a regular document rule. For the + * switch, only public API is used: the --vaadin-switch-* custom properties and the documented + * state attributes and parts. Document rules also win over document-level theme rules such as + * Aura's `vaadin-switch[checked] { ... }` by specificity. + * Without a size variant the switch keeps the native size of the active theme. + */ + +fc-toggle-button { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + --_toggle-button-gap: var(--lumo-space-xs, var(--vaadin-gap-xs)); + gap: var(--_toggle-button-gap); + font-family: var(--lumo-font-family, var(--aura-font-family)); + color: var(--lumo-body-text-color, var(--vaadin-text-color)); + -webkit-tap-highlight-color: transparent; + --toggle-button-primary-color: var(--lumo-primary-color, var(--aura-accent-color)); + --toggle-button-success-color: var(--lumo-success-color, var(--aura-green)); + --toggle-button-error-color: var(--lumo-error-color, var(--aura-red)); + --toggle-button-warning-color: var(--lumo-warning-color, var(--aura-yellow)); + --toggle-button-contrast-color: var(--lumo-contrast, var(--vaadin-text-color)); + --toggle-button-primary-text-color: var(--lumo-primary-text-color, var(--aura-accent-text-color)); + --toggle-button-success-text-color: var(--lumo-success-text-color, var(--aura-green-text)); + --toggle-button-error-text-color: var(--lumo-error-text-color, var(--aura-red-text)); + --toggle-button-warning-text-color: var(--lumo-warning-text-color, var(--aura-orange-text)); + --toggle-button-contrast-text-color: var(--lumo-body-text-color, var(--vaadin-text-color)); +} + +/* The display rules below would otherwise override the `hidden` attribute */ +fc-toggle-button[hidden], +fc-toggle-button [hidden] { + display: none !important; +} + +/* Field label */ +.fc-toggle-button-label { + font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); + font-weight: 500; + color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); + line-height: 1; + cursor: pointer; + user-select: none; +} + +fc-toggle-button:has(> .fc-toggle-button-row > vaadin-switch[required]) .fc-toggle-button-label::after { + content: ' *'; + color: var(--lumo-required-field-indicator-color, var(--vaadin-text-color-secondary)); +} + +/* The component shows its own required indicator next to the field label */ +fc-toggle-button > .fc-toggle-button-row > vaadin-switch::part(required-indicator) { + display: none; +} + +/* Row: [left-icon] [left-label] [switch] [right-label] [right-icon] */ +.fc-toggle-button-row { + display: flex; + align-items: center; + gap: var(--lumo-space-s, var(--vaadin-gap-s)); + cursor: pointer; + user-select: none; + /* Let the page scroll vertically while horizontal swipes reach the component */ + touch-action: pan-y; +} + +.fc-toggle-button-left-icon { order: 1; } +.fc-toggle-button-left-label { order: 2; } +.fc-toggle-button-row > vaadin-switch { order: 3; } +.fc-toggle-button-right-label { order: 4; } +.fc-toggle-button-right-icon { order: 5; } + +/* Icons inside: [left-label] [left-icon] [switch] [right-icon] [right-label] */ +fc-toggle-button[icons-inside] .fc-toggle-button-left-label { order: 1; } +fc-toggle-button[icons-inside] .fc-toggle-button-left-icon { order: 2; } +fc-toggle-button[icons-inside] .fc-toggle-button-right-icon { order: 4; } +fc-toggle-button[icons-inside] .fc-toggle-button-right-label { order: 5; } + +fc-toggle-button:has(> .fc-toggle-button-row > vaadin-switch:is([readonly], [disabled])) :is(.fc-toggle-button-label, .fc-toggle-button-row) { + cursor: default; +} + +fc-toggle-button:has(> .fc-toggle-button-row > vaadin-switch[disabled]) :is(.fc-toggle-button-left-label, .fc-toggle-button-right-label, .fc-toggle-button-left-icon, .fc-toggle-button-right-icon) { + opacity: 0.5; +} + +/* Side labels */ +:is(.fc-toggle-button-left-label, .fc-toggle-button-right-label) { + font-size: var(--lumo-font-size-s, var(--aura-font-size-s)); + font-weight: 500; + transition: color 0.3s; +} + +fc-toggle-button[theme~='small'] :is(.fc-toggle-button-left-label, .fc-toggle-button-right-label) { + font-size: var(--lumo-font-size-xs, var(--aura-font-size-xs)); +} + +fc-toggle-button[theme~='large'] :is(.fc-toggle-button-left-label, .fc-toggle-button-right-label) { + font-size: var(--lumo-font-size-l, var(--aura-font-size-l)); +} + +/* Label highlighting: the label on the active side uses the variant color, the other is dimmed */ +fc-toggle-button[highlight-label] { + --_toggle-button-active-text-color: var(--toggle-button-primary-text-color); +} + +fc-toggle-button[highlight-label][theme~='color-success'] { + --_toggle-button-active-text-color: var(--toggle-button-success-text-color); +} + +fc-toggle-button[highlight-label][theme~='color-error'] { + --_toggle-button-active-text-color: var(--toggle-button-error-text-color); +} + +fc-toggle-button[highlight-label][theme~='color-warning'] { + --_toggle-button-active-text-color: var(--toggle-button-warning-text-color); +} + +fc-toggle-button[highlight-label][theme~='color-contrast'] { + --_toggle-button-active-text-color: var(--toggle-button-contrast-text-color); +} + +fc-toggle-button[highlight-label]:not(:has(> .fc-toggle-button-row > vaadin-switch[checked])) .fc-toggle-button-left-label, +fc-toggle-button[highlight-label]:has(> .fc-toggle-button-row > vaadin-switch[checked]) .fc-toggle-button-right-label { + color: var(--_toggle-button-active-text-color); +} + +fc-toggle-button[highlight-label]:has(> .fc-toggle-button-row > vaadin-switch[checked]) .fc-toggle-button-left-label, +fc-toggle-button[highlight-label]:not(:has(> .fc-toggle-button-row > vaadin-switch[checked])) .fc-toggle-button-right-label { + color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); + opacity: 0.7; +} + +/* Helper text and error message */ +.fc-toggle-button-helper { + font-size: var(--lumo-font-size-xs, var(--aura-font-size-s)); + color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); +} + +.fc-toggle-button-error { + font-size: var(--lumo-font-size-xs, var(--aura-font-size-s)); + color: var(--lumo-error-text-color, var(--aura-red-text)); +} + +/* The error live region stays rendered when empty (not display: none), so cancel its gap instead */ +.fc-toggle-button-error:empty { + margin-top: calc(-1 * var(--_toggle-button-gap)); +} + +/* Size variants */ +fc-toggle-button[theme~='small'] > .fc-toggle-button-row > vaadin-switch { + --_toggle-button-width: 32px; + --vaadin-switch-width: var(--_toggle-button-width); + --vaadin-switch-height: 18px; + --vaadin-switch-marker-width: 14px; + --vaadin-switch-marker-height: 14px; +} + +fc-toggle-button[theme~='medium'] > .fc-toggle-button-row > vaadin-switch { + --_toggle-button-width: 44px; + --vaadin-switch-width: var(--_toggle-button-width); + --vaadin-switch-height: 24px; + --vaadin-switch-marker-width: 18px; + --vaadin-switch-marker-height: 18px; +} + +fc-toggle-button[theme~='large'] > .fc-toggle-button-row > vaadin-switch { + --_toggle-button-width: 56px; + --vaadin-switch-width: var(--_toggle-button-width); + --vaadin-switch-height: 32px; + --vaadin-switch-marker-width: 24px; + --vaadin-switch-marker-height: 24px; +} + +/* Long swipe: 28px wider than the chosen size (medium when no size is set). Must follow the sizes. */ +fc-toggle-button[theme~='longswipe'] > .fc-toggle-button-row > vaadin-switch { + --vaadin-switch-width: calc(var(--_toggle-button-width, 44px) + 28px); +} + +fc-toggle-button[theme~='longswipe']:not([theme~='small'], [theme~='large']) > .fc-toggle-button-row > vaadin-switch { + --vaadin-switch-height: 24px; + --vaadin-switch-marker-width: 18px; + --vaadin-switch-marker-height: 18px; +} + +/* Color variants: each one maps to a color, used by the rules below */ +fc-toggle-button[theme~='color-primary'] { --_toggle-button-variant-color: var(--toggle-button-primary-color); } +fc-toggle-button[theme~='color-success'] { --_toggle-button-variant-color: var(--toggle-button-success-color); } +fc-toggle-button[theme~='color-error'] { --_toggle-button-variant-color: var(--toggle-button-error-color); } +fc-toggle-button[theme~='color-warning'] { --_toggle-button-variant-color: var(--toggle-button-warning-color); } +fc-toggle-button[theme~='color-contrast'] { --_toggle-button-variant-color: var(--toggle-button-contrast-color); } + +/* + * Enabled, editable and checked: the track takes the variant color. Guarded by the variant list, + * so toggles without a color variant keep the core color. + */ +fc-toggle-button:is([theme~='color-primary'], [theme~='color-success'], [theme~='color-error'], [theme~='color-warning'], [theme~='color-contrast']) + > .fc-toggle-button-row > vaadin-switch[checked]:not([readonly], [disabled]) { + --vaadin-switch-background: var(--_toggle-button-variant-color); + --vaadin-switch-icon-color: var(--_toggle-button-variant-color); +} + +fc-toggle-button[theme~='color-contrast'] > .fc-toggle-button-row > vaadin-switch[checked]:not([readonly], [disabled]) { + --vaadin-switch-marker-color: var(--lumo-base-color, var(--vaadin-background-color)); +} + +/* + * Disabled + checked: the track keeps the variant color, faded, as in 1.x (where the whole + * component had 50% opacity). Without this, the core switch would tint it with the primary color. + */ +fc-toggle-button:is([theme~='color-primary'], [theme~='color-success'], [theme~='color-error'], [theme~='color-warning'], [theme~='color-contrast']) + > .fc-toggle-button-row > vaadin-switch[checked][disabled] { + --vaadin-switch-background: color-mix(in srgb, var(--_toggle-button-variant-color) 50%, transparent); + --vaadin-switch-icon-color: color-mix(in srgb, var(--_toggle-button-variant-color) 50%, transparent); +} + +/* + * Read-only + checked: the core read-only look, with the track border tinted with a desaturated + * version of the variant color (same mix as in 1.x, see #2) + */ +fc-toggle-button:is([theme~='color-primary'], [theme~='color-success'], [theme~='color-error'], [theme~='color-warning'], [theme~='color-contrast']) + > .fc-toggle-button-row > vaadin-switch[readonly][checked] { + --vaadin-switch-border-color: color-mix( + in srgb, + var(--_toggle-button-variant-color) 40%, + var(--lumo-contrast-30pct, var(--vaadin-border-color)) + ); +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonDemoView.java index df25f5c..35c3930 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonDemoView.java @@ -36,6 +36,7 @@ public ToggleButtonDemoView() { addDemo(ToggleButtonVariantsDemo.class); addDemo(ToggleButtonReadOnlyDemo.class); addDemo(ToggleButtonEventsDemo.class); + addDemo(ToggleButtonFormDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonFormDemo.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonFormDemo.java new file mode 100644 index 0000000..57919d2 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonFormDemo.java @@ -0,0 +1,139 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.togglebutton; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; + +@DemoSource +@PageTitle("Form Integration") +@SuppressWarnings("serial") +@Route(value = "togglebutton/form", layout = ToggleButtonDemoView.class) +public class ToggleButtonFormDemo extends Div { + + public static class Account { + private boolean deleteOnExit; + private boolean betaFeatures; + + public boolean isDeleteOnExit() { + return deleteOnExit; + } + + public void setDeleteOnExit(boolean deleteOnExit) { + this.deleteOnExit = deleteOnExit; + } + + public boolean isBetaFeatures() { + return betaFeatures; + } + + public void setBetaFeatures(boolean betaFeatures) { + this.betaFeatures = betaFeatures; + } + } + + public static class Booking { + private boolean confirmed; + + public boolean isConfirmed() { + return confirmed; + } + + public void setConfirmed(boolean confirmed) { + this.confirmed = confirmed; + } + } + + public ToggleButtonFormDemo() { + + ToggleButton withHelper = new ToggleButton("Audit log retention", true) + .setLeftLabel("Off") + .setRightLabel("On"); + withHelper.setId("with-helper"); + withHelper.setHelperText("Included on the Business plan."); + withHelper.setReadOnly(true); + + ToggleButton required = new ToggleButton("I confirm the trip details are correct") + .setLeftLabel("No") + .setRightLabel("Yes") + .withHighlightLabel(); + required.setId("required"); + required.setHelperText("Required to submit the booking."); + + Binder binder = new Binder<>(Booking.class); + binder.forField(required) + .asRequired("You must confirm the trip details to continue") + .bind(Booking::isConfirmed, Booking::setConfirmed); + binder.setBean(new Booking()); + + Button submit = new Button("Submit", e -> { + if (binder.validate().isOk()) { + Notification.show("Booking submitted"); + } + }); + submit.setId("submit"); + + ToggleButton swipe = new ToggleButton("Swipe me on a touch device") + .setLeftLabel("Off") + .setRightLabel("On"); + swipe.addThemeVariants(ToggleButtonVariant.LONGSWIPE, ToggleButtonVariant.SUCCESS); + swipe.addValueChangeListener(e -> + Notification.show("Value: " + e.getValue() + " (from client: " + e.isFromClient() + ")")); + + // Color variants on fields bound with Binder (no validators) + ToggleButton deleteOnExit = new ToggleButton("Delete account on exit") + .setLeftLabel("Off") + .setRightLabel("On") + .withHighlightLabel(); + deleteOnExit.setId("bound-error"); + deleteOnExit.addThemeVariants(ToggleButtonVariant.ERROR); + + ToggleButton betaFeatures = new ToggleButton("Beta features") + .setLeftLabel("Off") + .setRightLabel("On") + .withHighlightLabel(); + betaFeatures.setId("bound-warning"); + betaFeatures.addThemeVariants(ToggleButtonVariant.WARNING); + + Binder accountBinder = new Binder<>(Account.class); + accountBinder.forField(deleteOnExit).bind(Account::isDeleteOnExit, Account::setDeleteOnExit); + accountBinder.forField(betaFeatures).bind(Account::isBetaFeatures, Account::setBetaFeatures); + accountBinder.setBean(new Account()); + + add(new VerticalLayout( + new H3("Color variants with Binder"), + deleteOnExit, + betaFeatures, + new H3("Helper text (read-only)"), + withHelper, + new H3("Required with Binder"), + required, + submit, + new H3("Swipe"), + swipe)); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariantsDemo.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariantsDemo.java index 57950fa..5f73234 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariantsDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/ToggleButtonVariantsDemo.java @@ -92,11 +92,17 @@ public ToggleButtonVariantsDemo() { HorizontalLayout sizeRow = new HorizontalLayout(small, medium, large); HorizontalLayout longswipeRow = new HorizontalLayout(longswipeSmall, longswipeMedium, longswipeLarge); HorizontalLayout statesRow = new HorizontalLayout(disabled, readOnly); + + // Disabled color variants + HorizontalLayout disabledOnRow = disabledColorRow(true); + HorizontalLayout disabledOffRow = disabledColorRow(false); // #if vaadin eq 0 colorRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); sizeRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); longswipeRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); statesRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); + disabledOnRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); + disabledOffRow.getStyle().set("gap", "var(--lumo-space-l, var(--vaadin-gap-l))"); // #endif add(new VerticalLayout( @@ -107,6 +113,27 @@ public ToggleButtonVariantsDemo() { new H3("Long swipe"), longswipeRow, new H3("States"), - statesRow)); + statesRow, + new H3("Disabled colors - checked"), + disabledOnRow, + new H3("Disabled colors - unchecked"), + disabledOffRow)); + } + + private static HorizontalLayout disabledColorRow(boolean checked) { + HorizontalLayout row = new HorizontalLayout(); + row.add(disabledColor(checked, "Primary", ToggleButtonVariant.PRIMARY)); + row.add(disabledColor(checked, "Success", ToggleButtonVariant.SUCCESS)); + row.add(disabledColor(checked, "Error", ToggleButtonVariant.ERROR)); + row.add(disabledColor(checked, "Warning", ToggleButtonVariant.WARNING)); + row.add(disabledColor(checked, "Contrast", ToggleButtonVariant.CONTRAST)); + return row; + } + + private static ToggleButton disabledColor(boolean checked, String label, ToggleButtonVariant variant) { + ToggleButton toggle = new ToggleButton(checked).setLeftLabel(label); + toggle.addThemeVariants(variant); + toggle.setEnabled(false); + return toggle; } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/AbstractViewTest.java index d4d7da8..313c432 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/AbstractViewTest.java @@ -21,6 +21,7 @@ import com.vaadin.testbench.ScreenshotOnFailureRule; import com.vaadin.testbench.TestBench; +import com.vaadin.testbench.TestBenchElement; import com.vaadin.testbench.parallel.ParallelTest; import io.github.bonigarcia.wdm.WebDriverManager; import org.junit.Before; @@ -72,6 +73,32 @@ public void setup() throws Exception { getDriver().get(getURL(route)); } + /** + * Returns the {@code checked} attribute of the core switch inside a toggle button. + * + * @param toggle the {@code fc-toggle-button} element + * @return the attribute value, or {@code null} when the switch is off + */ + protected static String checkedOf(TestBenchElement toggle) { + return toggle.$("vaadin-switch").first().getAttribute("checked"); + } + + /** + * Clicks an element inside a toggle button, or the toggle button itself when the selector is + * {@code null}. + * + * @param toggle the {@code fc-toggle-button} element + * @param selector a CSS selector relative to the toggle button, or {@code null} + */ + protected static void clickIn(TestBenchElement toggle, String selector) { + toggle + .getCommandExecutor() + .executeScript( + "(arguments[1] ? arguments[0].querySelector(arguments[1]) : arguments[0]).click()", + toggle, + selector); + } + /** * Returns deployment host name concatenated with route. * diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonIT.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonIT.java index 9e955ec..ddb963b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonIT.java @@ -30,7 +30,7 @@ public class ToggleButtonIT extends AbstractViewTest { public ToggleButtonIT() { - super("labels"); + super("togglebutton/labels"); } private TestBenchElement getToggle(String id) { @@ -39,22 +39,39 @@ private TestBenchElement getToggle(String id) { @Test public void initialValueIsFalse() { - assertNull(getToggle("basic").getAttribute("checked")); + assertNull(checkedOf(getToggle("basic"))); } @Test public void clickTogglesChecked() { TestBenchElement toggle = getToggle("basic"); - toggle.click(); - assertNotNull(toggle.getAttribute("checked")); + clickIn(toggle, "vaadin-switch input"); + assertNotNull(checkedOf(toggle)); } @Test public void clickAgainTogglesBack() { TestBenchElement toggle = getToggle("basic"); - toggle.click(); - toggle.click(); - assertNull(toggle.getAttribute("checked")); + clickIn(toggle, "vaadin-switch input"); + assertNotNull(checkedOf(toggle)); + clickIn(toggle, "vaadin-switch input"); + assertNull(checkedOf(toggle)); + } + + @Test + public void clickOnSideLabelToggles() { + TestBenchElement toggle = getToggle("with-right-label"); + clickIn(toggle, ".fc-toggle-button-right-label"); + assertNotNull(checkedOf(toggle)); + } + + @Test + public void clickOnBlankSpaceDoesNotToggle() { + // A click whose target is the host itself (e.g. blank space when the component is wider than + // its content) must not toggle + TestBenchElement toggle = getToggle("with-right-label"); + clickIn(toggle, null); + assertNull(checkedOf(toggle)); } @Test @@ -65,7 +82,7 @@ public void leftLabelIsRendered() { toggle .getCommandExecutor() .executeScript( - "return arguments[0].shadowRoot.querySelector('.label').textContent", toggle); + "return arguments[0].querySelector('.fc-toggle-button-left-label:not([hidden]), .fc-toggle-button-right-label:not([hidden])').textContent", toggle); assertEquals("Off", text); } @@ -77,7 +94,7 @@ public void rightLabelIsRendered() { toggle .getCommandExecutor() .executeScript( - "return arguments[0].shadowRoot.querySelector('.label').textContent", toggle); + "return arguments[0].querySelector('.fc-toggle-button-left-label:not([hidden]), .fc-toggle-button-right-label:not([hidden])').textContent", toggle); assertEquals("On", text); } @@ -89,7 +106,7 @@ public void bothLabelsAreRendered() { toggle .getCommandExecutor() .executeScript( - "return Array.from(arguments[0].shadowRoot.querySelectorAll('.label'))" + "return Array.from(arguments[0].querySelectorAll('.fc-toggle-button-left-label:not([hidden]), .fc-toggle-button-right-label:not([hidden])'))" + ".map(l => l.textContent).join(',')", toggle); assertEquals("Off,On", texts); @@ -97,11 +114,26 @@ public void bothLabelsAreRendered() { @Test public void highlightLabelAttributeIsReflected() { - assertNotNull(getToggle("highlight-primary").getAttribute("highlightlabel")); + assertNotNull(getToggle("highlight-primary").getAttribute("highlight-label")); + } + + @Test + public void switchIsNamedBySideLabelsWhenThereIsNoFieldLabel() { + TestBenchElement toggle = getToggle("with-both-labels"); + String name = + (String) + toggle + .getCommandExecutor() + .executeScript( + "const input = arguments[0].querySelector('vaadin-switch input');" + + "return input.getAttribute('role') + ':' + input.getAttribute('aria-labelledby')" + + ".split(' ').map(id => document.getElementById(id).textContent).join(' ')", + toggle); + assertEquals("switch:Off On", name); } @Test public void noHighlightLabelByDefault() { - assertNull(getToggle("basic").getAttribute("highlightlabel")); + assertNull(getToggle("basic").getAttribute("highlight-label")); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonVariantsIT.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonVariantsIT.java index 90392b0..6bbb0af 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonVariantsIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ToggleButtonVariantsIT.java @@ -30,7 +30,7 @@ public class ToggleButtonVariantsIT extends AbstractViewTest { public ToggleButtonVariantsIT() { - super("variants"); + super("togglebutton/variants"); } private TestBenchElement getToggle(String id) { @@ -41,27 +41,27 @@ private TestBenchElement getToggle(String id) { @Test public void primaryVariantHasThemeAttribute() { - assertTrue(getToggle("primary").getAttribute("theme").contains("primary")); + assertTrue(getToggle("primary").getAttribute("theme").contains("color-primary")); } @Test public void successVariantHasThemeAttribute() { - assertTrue(getToggle("success").getAttribute("theme").contains("success")); + assertTrue(getToggle("success").getAttribute("theme").contains("color-success")); } @Test public void errorVariantHasThemeAttribute() { - assertTrue(getToggle("error").getAttribute("theme").contains("error")); + assertTrue(getToggle("error").getAttribute("theme").contains("color-error")); } @Test public void warningVariantHasThemeAttribute() { - assertTrue(getToggle("warning").getAttribute("theme").contains("warning")); + assertTrue(getToggle("warning").getAttribute("theme").contains("color-warning")); } @Test public void contrastVariantHasThemeAttribute() { - assertTrue(getToggle("contrast").getAttribute("theme").contains("contrast")); + assertTrue(getToggle("contrast").getAttribute("theme").contains("color-contrast")); } // --- Size variants --- @@ -86,14 +86,14 @@ public void largeVariantHasThemeAttribute() { @Test public void colorVariantsInitializedChecked() { for (String id : new String[] {"primary", "success", "error", "warning", "contrast"}) { - assertNotNull("Expected " + id + " to be checked", getToggle(id).getAttribute("checked")); + assertNotNull("Expected " + id + " to be checked", checkedOf(getToggle(id))); } } @Test public void sizeVariantsInitializedUnchecked() { for (String id : new String[] {"small", "medium", "large"}) { - assertNull("Expected " + id + " to be unchecked", getToggle(id).getAttribute("checked")); + assertNull("Expected " + id + " to be unchecked", checkedOf(getToggle(id))); } } @@ -102,16 +102,18 @@ public void sizeVariantsInitializedUnchecked() { @Test public void disabledButtonCannotBeToggled() { TestBenchElement disabled = getToggle("disabled"); - assertNull(disabled.getAttribute("checked")); - disabled.click(); - assertNull(disabled.getAttribute("checked")); + assertNull(checkedOf(disabled)); + clickIn(disabled, "vaadin-switch input"); + clickIn(disabled, ".fc-toggle-button-right-label"); + assertNull(checkedOf(disabled)); } @Test public void readonlyButtonCannotBeToggled() { TestBenchElement readOnly = getToggle("read-only"); - assertNotNull(readOnly.getAttribute("checked")); - readOnly.click(); - assertNotNull(readOnly.getAttribute("checked")); + assertNotNull(checkedOf(readOnly)); + clickIn(readOnly, "vaadin-switch input"); + clickIn(readOnly, ".fc-toggle-button-right-label"); + assertNotNull(checkedOf(readOnly)); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ViewIT.java index f38ba2b..daf77ed 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/it/ViewIT.java @@ -22,6 +22,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import com.vaadin.testbench.TestBenchElement; import org.hamcrest.Description; @@ -31,6 +32,10 @@ public class ViewIT extends AbstractViewTest { + public ViewIT() { + super("togglebutton"); + } + private Matcher hasBeenUpgradedToCustomElement = new TypeSafeDiagnosingMatcher() { @@ -57,7 +62,13 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { + // fc-toggle-button has no shadow DOM of its own; it must be defined and wrap an upgraded switch TestBenchElement element = $("fc-toggle-button").first(); - assertThat(element, hasBeenUpgradedToCustomElement); + assertTrue( + (Boolean) + element + .getCommandExecutor() + .executeScript("return !!customElements.get('fc-toggle-button')")); + assertThat(element.$("vaadin-switch").first(), hasBeenUpgradedToCustomElement); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/AccessibleNameTest.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/AccessibleNameTest.java new file mode 100644 index 0000000..f365241 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/AccessibleNameTest.java @@ -0,0 +1,82 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.togglebutton.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.togglebutton.ToggleButton; +import com.vaadin.flow.dom.Element; +import java.util.Optional; +import org.junit.Test; + +/** Accessible name of the inner switch: aria-labelledby, then aria-label, then the labels. */ +public class AccessibleNameTest { + + private static Element switchOf(ToggleButton toggle) { + return find(toggle.getElement()).orElseThrow(); + } + + private static Optional find(Element element) { + if (element.isTextNode()) { + return Optional.empty(); + } + if ("vaadin-switch".equals(element.getTag())) { + return Optional.of(element); + } + return element.getChildren().map(AccessibleNameTest::find).flatMap(Optional::stream).findFirst(); + } + + private static String labelledBy(ToggleButton toggle) { + return switchOf(toggle).getProperty("accessibleNameRef"); + } + + @Test + public void ariaLabelledByIsKeptWhenLabelsChange() { + ToggleButton toggle = new ToggleButton(); + toggle.setAriaLabelledBy("heading"); + toggle.setLeftLabel("Off"); + toggle.setRightLabel("On"); + toggle.setLabel("Notifications"); + toggle.setAriaLabel("Enable notifications"); + + assertEquals("heading", labelledBy(toggle)); + assertNull(switchOf(toggle).getProperty("accessibleName")); + assertEquals(Optional.of("heading"), toggle.getAriaLabelledBy()); + } + + @Test + public void ariaLabelledByIsEmptyUnlessSetByTheCaller() { + ToggleButton toggle = new ToggleButton("Notifications"); + // The switch references the internal field label, but that is not exposed as the caller's value + assertTrue(labelledBy(toggle).endsWith("-label")); + assertEquals(Optional.empty(), toggle.getAriaLabelledBy()); + } + + @Test + public void clearingAriaLabelledByRestoresTheFieldLabel() { + ToggleButton toggle = new ToggleButton("Notifications"); + String fieldLabel = labelledBy(toggle); + toggle.setAriaLabelledBy("heading"); + toggle.setAriaLabelledBy((String) null); + assertEquals(fieldLabel, labelledBy(toggle)); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/BinderThemeTest.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/BinderThemeTest.java new file mode 100644 index 0000000..94aa303 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/BinderThemeTest.java @@ -0,0 +1,80 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.togglebutton.test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.togglebutton.ToggleButton; +import com.flowingcode.vaadin.addons.togglebutton.ToggleButtonVariant; +import com.vaadin.flow.data.binder.Binder; +import org.junit.Test; + +/** + * Binder adds a theme named after the error level ("error", "warning", ...) to invalid fields, and + * removes all of those names when the field becomes valid. Color variants must not use those names, + * or Binder would remove them. + */ +public class BinderThemeTest { + + public static class Bean { + private boolean value; + + public boolean isValue() { + return value; + } + + public void setValue(boolean value) { + this.value = value; + } + } + + private static ToggleButton bind(ToggleButtonVariant variant) { + ToggleButton toggle = new ToggleButton(); + toggle.addThemeVariants(variant); + Binder binder = new Binder<>(Bean.class); + binder.forField(toggle).bind(Bean::isValue, Bean::setValue); + // setBean validates, and a valid field gets the error-level theme names removed + binder.setBean(new Bean()); + toggle.setValue(true); + return toggle; + } + + @Test + public void errorVariantSurvivesBinderValidation() { + assertTrue(bind(ToggleButtonVariant.ERROR).hasThemeName("color-error")); + } + + @Test + public void warningVariantSurvivesBinderValidation() { + assertTrue(bind(ToggleButtonVariant.WARNING).hasThemeName("color-warning")); + } + + @Test + public void invalidFieldDoesNotLookLikeErrorVariant() { + ToggleButton toggle = new ToggleButton(); + Binder binder = new Binder<>(Bean.class); + binder.forField(toggle).asRequired("Required").bind(Bean::isValue, Bean::setValue); + binder.setBean(new Bean()); + binder.validate(); + assertTrue(toggle.isInvalid()); + assertFalse(toggle.hasThemeName("color-error")); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/SignalBindingTest.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/SignalBindingTest.java new file mode 100644 index 0000000..8905946 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/SignalBindingTest.java @@ -0,0 +1,116 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.togglebutton.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.togglebutton.ToggleButton; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.checkbox.Switch; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.signals.local.ValueSignal; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** The bind* defaults inherited from Flow must reach the inner switch and the rendered parts. */ +public class SignalBindingTest { + + private UI ui; + private ToggleButton toggle; + + @Before + public void setUp() { + ui = new UI(); + UI.setCurrent(ui); + toggle = new ToggleButton("Notifications"); + ui.add(toggle); + } + + @After + public void tearDown() { + UI.setCurrent(null); + } + + private Switch switchOf() { + return (Switch) find(toggle.getElement()).flatMap(Element::getComponent).orElseThrow(); + } + + private static Optional find(Element element) { + if (element.isTextNode()) { + return Optional.empty(); + } + if ("vaadin-switch".equals(element.getTag())) { + return Optional.of(element); + } + return element.getChildren().map(SignalBindingTest::find).flatMap(Optional::stream).findFirst(); + } + + private Element helper() { + return toggle.getElement().getChildren() + .filter(e -> e.getClassList().contains("fc-toggle-button-helper")) + .findFirst().orElseThrow(); + } + + @Test + public void bindReadOnlyReachesTheSwitch() { + ValueSignal readOnly = new ValueSignal<>(true); + toggle.bindReadOnly(readOnly); + assertTrue(toggle.isReadOnly()); + assertTrue(switchOf().isReadOnly()); + + readOnly.set(false); + assertFalse(toggle.isReadOnly()); + assertFalse(switchOf().isReadOnly()); + } + + @Test + public void bindRequiredIndicatorVisibleReachesTheSwitch() { + ValueSignal required = new ValueSignal<>(true); + toggle.bindRequiredIndicatorVisible(required); + assertTrue(toggle.isRequiredIndicatorVisible()); + assertTrue(switchOf().isRequiredIndicatorVisible()); + + required.set(false); + assertFalse(switchOf().isRequiredIndicatorVisible()); + } + + @Test + public void bindHelperTextRendersAndDescribesTheSwitch() { + ValueSignal helperText = new ValueSignal<>("Included on the Business plan."); + toggle.bindHelperText(helperText); + String helperId = helper().getAttribute("id"); + + assertEquals("Included on the Business plan.", toggle.getHelperText()); + assertTrue(helper().isVisible()); + assertEquals(helperId, switchOf().getAriaDescribedBy().orElse(null)); + + helperText.set("Not included."); + assertEquals("Not included.", toggle.getHelperText()); + + helperText.set(""); + assertFalse(helper().isVisible()); + assertNull(switchOf().getAriaDescribedBy().orElse(null)); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/ToggleButtonTest.java b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/ToggleButtonTest.java new file mode 100644 index 0000000..e43ead9 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/togglebutton/test/ToggleButtonTest.java @@ -0,0 +1,129 @@ +/*- + * #%L + * Toggle Button Add-On + * %% + * Copyright (C) 2026 Flowing Code + * %% + * 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. + * #L% + */ +package com.flowingcode.vaadin.addons.togglebutton.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.togglebutton.ToggleButton; +import com.vaadin.flow.component.BlurNotifier.BlurEvent; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.FocusNotifier.FocusEvent; +import com.vaadin.flow.component.checkbox.Switch; +import com.vaadin.flow.dom.Element; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.Test; + +public class ToggleButtonTest { + + private static Switch switchOf(ToggleButton toggle) { + return (Switch) find(toggle.getElement()).flatMap(Element::getComponent).orElseThrow(); + } + + private static Optional find(Element element) { + if (element.isTextNode()) { + return Optional.empty(); + } + if ("vaadin-switch".equals(element.getTag())) { + return Optional.of(element); + } + return element.getChildren().map(ToggleButtonTest::find).flatMap(Optional::stream).findFirst(); + } + + @Test + public void nullValueIsRejected() { + ToggleButton toggle = new ToggleButton(true); + assertThrows(NullPointerException.class, () -> toggle.setValue(null)); + assertEquals(Boolean.TRUE, toggle.getValue()); + } + + @Test + public void labelIsNullUntilSet() { + ToggleButton toggle = new ToggleButton(); + assertNull(toggle.getLabel()); + toggle.setLabel("Notifications"); + assertEquals("Notifications", toggle.getLabel()); + toggle.setLabel(null); + assertNull(toggle.getLabel()); + } + + @Test + public void tabIndexGoesToTheSwitch() { + ToggleButton toggle = new ToggleButton(); + toggle.setTabIndex(-1); + assertEquals(-1, toggle.getTabIndex()); + assertEquals("-1", switchOf(toggle).getElement().getAttribute("tabindex")); + assertFalse(toggle.getElement().hasAttribute("tabindex")); + } + + @Test + public void errorRegionStaysRenderedAndOnlyItsTextChanges() { + ToggleButton toggle = new ToggleButton(); + Element error = toggle.getElement().getChildren() + .filter(e -> e.getClassList().contains("fc-toggle-button-error")) + .findFirst().orElseThrow(); + String errorId = error.getAttribute("id"); + + toggle.setErrorMessage("Required"); + assertTrue(error.isVisible()); + assertEquals("", error.getText()); + + toggle.setInvalid(true); + assertTrue(error.isVisible()); + assertEquals("Required", error.getText()); + assertEquals("assertive", error.getAttribute("aria-live")); + assertTrue(switchOf(toggle).getAriaDescribedBy().orElse("").contains(errorId)); + + toggle.setInvalid(false); + assertTrue(error.isVisible()); + assertEquals("", error.getText()); + assertFalse(switchOf(toggle).getAriaDescribedBy().orElse("").contains(errorId)); + } + + @Test + public void idsAreUniqueAcrossInstances() { + String a = switchOf(new ToggleButton("A")).getAriaLabelledBy().orElseThrow(); + String b = switchOf(new ToggleButton("B")).getAriaLabelledBy().orElseThrow(); + assertNotEquals(a, b); + } + + @Test + public void focusAndBlurListenersFireFromTheSwitch() { + ToggleButton toggle = new ToggleButton(); + List sources = new ArrayList<>(); + toggle.addFocusListener(e -> sources.add(e.getSource())); + toggle.addBlurListener(e -> sources.add(e.getSource())); + + Switch inner = switchOf(toggle); + ComponentUtil.fireEvent(inner, new FocusEvent<>(inner, true)); + ComponentUtil.fireEvent(inner, new BlurEvent<>(inner, true)); + + assertEquals(2, sources.size()); + assertSame(toggle, sources.get(0)); + assertSame(toggle, sources.get(1)); + } +}