Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions flow-server/src/main/java/com/vaadin/flow/dom/ClassList.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import java.io.Serializable;
import java.util.Set;

import com.vaadin.signals.Signal;

/**
* Representation of the class names for an {@link Element}.
*
Expand All @@ -44,4 +46,32 @@ default boolean set(String className, boolean set) {
}
}

/**
* Binds the presence of the given class name to the provided signal so that
* the class is added when the signal value is {@code true} and removed when
* the value is {@code false}.
* <p>
* Passing {@code null} as the {@code signal} removes any existing binding
* for the given class name. When unbinding, the current presence of the
* class is left unchanged.
* <p>
* While a binding for the given class name is active, manual calls to
* {@link #add(Object)}, {@link #remove(Object)} or
* {@link #set(String, boolean)} for that name will throw a
* {@code com.vaadin.flow.dom.BindingActiveException}. Bindings are
* lifecycle-aware and only active while the owning {@link Element} is
* attached; they are deactivated while the element is detached.
* <p>
* Bulk operations that indiscriminately replace or clear the class list
* (for example {@link #clear()} or setting the {@code class} attribute via
* {@link Element#setAttribute(String, String)}) clear all bindings.
*
* @param name
* the class name to bind, not {@code null} or blank
* @param signal
* the boolean signal to bind to, or {@code null} to unbind
* @since 25.0
*/
void bind(String name, Signal<Boolean> signal);

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.Iterator;

import com.vaadin.flow.dom.ClassList;
import com.vaadin.signals.Signal;

/**
* Immutable class list implementation.
Expand Down Expand Up @@ -63,4 +64,15 @@ public Iterator<String> iterator() {
public int size() {
return values.size();
}

/**
* {@inheritDoc}
* <p>
* Text nodes do not support binding a {@link Signal} to a stylesheet class,
* because they do not support styling in general.
*/
@Override
public void bind(String name, Signal<Boolean> signal) {
throw new UnsupportedOperationException(CANT_MODIFY_MESSAGE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
*/
package com.vaadin.flow.internal.nodefeature;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

import com.vaadin.flow.dom.ClassList;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.dom.ElementEffect;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.shared.Registration;
import com.vaadin.signals.BindingActiveException;
import com.vaadin.signals.Signal;

/**
* Handles CSS class names for an element.
Expand All @@ -28,11 +37,16 @@
*/
public class ElementClassList extends SerializableNodeList<String> {

private Map<String, SignalBinding> bindingsByName;

private static class ClassListView extends NodeList.SetView<String>
implements ClassList {

private final ElementClassList elementClassList;

private ClassListView(ElementClassList elementClassList) {
super(elementClassList);
this.elementClassList = elementClassList;
}

@Override
Expand All @@ -41,7 +55,7 @@ protected void validate(String className) {
throw new IllegalArgumentException("Class name cannot be null");
}

if ("".equals(className)) {
if (className.isEmpty()) {
throw new IllegalArgumentException(
"Class name cannot be empty");
}
Expand All @@ -50,6 +64,104 @@ protected void validate(String className) {
"Class name cannot contain spaces");
}
}

private void internalSetPresence(String name, boolean set) {
// Directly mutate the underlying NodeList to bypass SetView
// add/remove overrides which enforce BindingActiveException for
// manual updates.
ElementClassList list = this.elementClassList;
int index = list.indexOf(name);
if (set) {
if (index == -1) {
// append at the end
list.add(list.size(), name);
}
} else {
if (index != -1) {
list.remove(index);
}
}
}

private Map<String, SignalBinding> getBindings() {
return elementClassList.getBindings();
}

private boolean isBound(String name) {
return elementClassList.isBound(name);
}

private StateNode getNode() {
return elementClassList.getNode();
}

@Override
public void bind(String name, Signal<Boolean> signal) {
validate(name);
if (signal == null) {
// Unbind: remove existing binding and leave the current class
// presence as-is
if (isBound(name)) {
SignalBinding old = getBindings().remove(name);
if (old != null && old.registration != null) {
old.registration.remove();
}
}
return;
}

// Replace any existing binding
SignalBinding existing = getBindings().remove(name);
if (existing != null && existing.registration != null) {
existing.registration.remove();
}
Element owner = Element.get(getNode());
Registration registration = ElementEffect.bind(owner, signal,
(element, value) -> internalSetPresence(name,
Boolean.TRUE.equals(value)));
SignalBinding binding = new SignalBinding(signal, registration,
name);
getBindings().put(name, binding);
}

@Override
public boolean add(String className) {
if (isBound(className)) {
throw new BindingActiveException("Class name '" + className
+ "' is bound and cannot be modified manually");
}
return super.add(className);
}

@Override
public boolean remove(Object className) {
if (className instanceof String name) {
if (isBound(name)) {
throw new BindingActiveException("Class name '" + name
+ "' is bound and cannot be modified manually");
}
}
return super.remove(className);
}

@Override
public void clear() {
clearBindings();
super.clear();
}

// Bulk operations in AbstractCollection ultimately delegate to
// add/remove
// which are guarded above. No need to override
// addAll/removeAll/retainAll
// unless optimization is required.

/**
* Clears all signal bindings.
*/
public void clearBindings() {
elementClassList.clearBindings();
}
}

/**
Expand All @@ -70,4 +182,31 @@ public ElementClassList(StateNode node) {
public ClassList getClassList() {
return new ClassListView(this);
}

private Map<String, SignalBinding> getBindings() {
if (bindingsByName == null) {
bindingsByName = new HashMap<>();
}
return bindingsByName;
}

private boolean isBound(String name) {
return bindingsByName != null && bindingsByName.containsKey(name);
}

private void clearBindings() {
if (bindingsByName == null || bindingsByName.isEmpty()) {
return;
}
for (SignalBinding binding : bindingsByName.values()) {
if (binding.registration != null) {
binding.registration.remove();
}
}
bindingsByName.clear();
}

private record SignalBinding(Signal<Boolean> signal,
Registration registration, String name) implements Serializable {
}
}
Loading
Loading