Skip to content

Commit 8a177ec

Browse files
authored
feat: Add Signal binding to class name list (#22706)
Allows toggle CSS class names for an element by changing boolean signal values. Fixes #22670
1 parent 82cf027 commit 8a177ec

File tree

6 files changed

+553
-1
lines changed

6 files changed

+553
-1
lines changed

flow-server/src/main/java/com/vaadin/flow/dom/ClassList.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import java.io.Serializable;
1919
import java.util.Set;
2020

21+
import com.vaadin.signals.Signal;
22+
2123
/**
2224
* Representation of the class names for an {@link Element}.
2325
*
@@ -44,4 +46,35 @@ default boolean set(String className, boolean set) {
4446
}
4547
}
4648

49+
/**
50+
* Binds the presence of the given class name to the provided signal so that
51+
* the class is added when the signal value is {@code true} and removed when
52+
* the value is {@code false}.
53+
* <p>
54+
* Passing {@code null} as the {@code signal} removes any existing binding
55+
* for the given class name. When unbinding, the current presence of the
56+
* class is left unchanged.
57+
* <p>
58+
* While a binding for the given class name is active, manual calls to
59+
* {@link #add(Object)}, {@link #remove(Object)} or
60+
* {@link #set(String, boolean)} for that name will throw a
61+
* {@code com.vaadin.flow.dom.BindingActiveException}. Bindings are
62+
* lifecycle-aware and only active while the owning {@link Element} is in
63+
* attached state; they are deactivated while the element is in detached
64+
* state.
65+
* <p>
66+
* Bulk operations that indiscriminately replace or clear the class list
67+
* (for example {@link #clear()} or setting the {@code class} attribute via
68+
* {@link Element#setAttribute(String, String)}) clear all bindings.
69+
*
70+
* @param name
71+
* the class name to bind, not {@code null} or blank
72+
* @param signal
73+
* the boolean signal to bind to, or {@code null} to unbind
74+
* @throws com.vaadin.signals.BindingActiveException
75+
* thrown when there is already an existing binding
76+
* @since 25.0
77+
*/
78+
void bind(String name, Signal<Boolean> signal);
79+
4780
}

flow-server/src/main/java/com/vaadin/flow/dom/impl/ImmutableClassList.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Iterator;
2424

2525
import com.vaadin.flow.dom.ClassList;
26+
import com.vaadin.signals.Signal;
2627

2728
/**
2829
* Immutable class list implementation.
@@ -63,4 +64,15 @@ public Iterator<String> iterator() {
6364
public int size() {
6465
return values.size();
6566
}
67+
68+
/**
69+
* {@inheritDoc}
70+
* <p>
71+
* Text nodes do not support binding a {@link Signal} to a stylesheet class,
72+
* because they do not support styling in general.
73+
*/
74+
@Override
75+
public void bind(String name, Signal<Boolean> signal) {
76+
throw new UnsupportedOperationException(CANT_MODIFY_MESSAGE);
77+
}
6678
}

flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementClassList.java

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@
1515
*/
1616
package com.vaadin.flow.internal.nodefeature;
1717

18+
import java.io.Serializable;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
1822
import com.vaadin.flow.dom.ClassList;
23+
import com.vaadin.flow.dom.Element;
24+
import com.vaadin.flow.dom.ElementEffect;
1925
import com.vaadin.flow.internal.StateNode;
26+
import com.vaadin.flow.shared.Registration;
27+
import com.vaadin.signals.BindingActiveException;
28+
import com.vaadin.signals.Signal;
2029

2130
/**
2231
* Handles CSS class names for an element.
@@ -28,11 +37,16 @@
2837
*/
2938
public class ElementClassList extends SerializableNodeList<String> {
3039

40+
private Map<String, SignalBinding> bindingsByName;
41+
3142
private static class ClassListView extends NodeList.SetView<String>
3243
implements ClassList {
3344

45+
private final ElementClassList elementClassList;
46+
3447
private ClassListView(ElementClassList elementClassList) {
3548
super(elementClassList);
49+
this.elementClassList = elementClassList;
3650
}
3751

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

44-
if ("".equals(className)) {
58+
if (className.isEmpty()) {
4559
throw new IllegalArgumentException(
4660
"Class name cannot be empty");
4761
}
@@ -50,6 +64,103 @@ protected void validate(String className) {
5064
"Class name cannot contain spaces");
5165
}
5266
}
67+
68+
private void internalSetPresence(String name, boolean set) {
69+
// Directly mutate the underlying NodeList to bypass SetView
70+
// add/remove overrides which enforce BindingActiveException for
71+
// manual updates.
72+
ElementClassList list = this.elementClassList;
73+
int index = list.indexOf(name);
74+
if (set) {
75+
if (index == -1) {
76+
// append at the end
77+
list.add(list.size(), name);
78+
}
79+
} else {
80+
if (index != -1) {
81+
list.remove(index);
82+
}
83+
}
84+
}
85+
86+
private Map<String, SignalBinding> getBindings() {
87+
return elementClassList.getBindings();
88+
}
89+
90+
private boolean isBound(String name) {
91+
return elementClassList.isBound(name);
92+
}
93+
94+
private StateNode getNode() {
95+
return elementClassList.getNode();
96+
}
97+
98+
@Override
99+
public void bind(String name, Signal<Boolean> signal) {
100+
validate(name);
101+
if (signal == null) {
102+
// Unbind: remove existing binding and leave the current class
103+
// presence as-is
104+
if (isBound(name)) {
105+
SignalBinding old = getBindings().remove(name);
106+
if (old != null && old.registration != null) {
107+
old.registration.remove();
108+
}
109+
}
110+
return;
111+
}
112+
113+
if (isBound(name)) {
114+
throw new BindingActiveException("Class name '" + name
115+
+ "' is already bound to a signal");
116+
}
117+
Element owner = Element.get(getNode());
118+
Registration registration = ElementEffect.bind(owner, signal,
119+
(element, value) -> internalSetPresence(name,
120+
Boolean.TRUE.equals(value)));
121+
SignalBinding binding = new SignalBinding(signal, registration,
122+
name);
123+
getBindings().put(name, binding);
124+
}
125+
126+
@Override
127+
public boolean add(String className) {
128+
if (isBound(className)) {
129+
throw new BindingActiveException("Class name '" + className
130+
+ "' is bound and cannot be modified manually");
131+
}
132+
return super.add(className);
133+
}
134+
135+
@Override
136+
public boolean remove(Object className) {
137+
if (className instanceof String name) {
138+
if (isBound(name)) {
139+
throw new BindingActiveException("Class name '" + name
140+
+ "' is bound and cannot be modified manually");
141+
}
142+
}
143+
return super.remove(className);
144+
}
145+
146+
@Override
147+
public void clear() {
148+
clearBindings();
149+
super.clear();
150+
}
151+
152+
// Bulk operations in AbstractCollection ultimately delegate to
153+
// add/remove
154+
// which are guarded above. No need to override
155+
// addAll/removeAll/retainAll
156+
// unless optimization is required.
157+
158+
/**
159+
* Clears all signal bindings.
160+
*/
161+
public void clearBindings() {
162+
elementClassList.clearBindings();
163+
}
53164
}
54165

55166
/**
@@ -70,4 +181,31 @@ public ElementClassList(StateNode node) {
70181
public ClassList getClassList() {
71182
return new ClassListView(this);
72183
}
184+
185+
private Map<String, SignalBinding> getBindings() {
186+
if (bindingsByName == null) {
187+
bindingsByName = new HashMap<>();
188+
}
189+
return bindingsByName;
190+
}
191+
192+
private boolean isBound(String name) {
193+
return bindingsByName != null && bindingsByName.containsKey(name);
194+
}
195+
196+
private void clearBindings() {
197+
if (bindingsByName == null || bindingsByName.isEmpty()) {
198+
return;
199+
}
200+
for (SignalBinding binding : bindingsByName.values()) {
201+
if (binding.registration != null) {
202+
binding.registration.remove();
203+
}
204+
}
205+
bindingsByName.clear();
206+
}
207+
208+
private record SignalBinding(Signal<Boolean> signal,
209+
Registration registration, String name) implements Serializable {
210+
}
73211
}

0 commit comments

Comments
 (0)