Skip to content

Commit 2d180d7

Browse files
authored
feat: Add signal binding for style property (#22752)
Allows a string signal binding for style properties, e.g.: ValueSignal<String> backgroundColor = new ValueSignal<>("red"); textField.getStyle().bind("background-color", backgroundColor); Fixes #22671
1 parent 89a22c6 commit 2d180d7

File tree

9 files changed

+737
-50
lines changed

9 files changed

+737
-50
lines changed

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import java.util.stream.Stream;
2020

2121
import com.vaadin.flow.component.page.ColorScheme;
22+
import com.vaadin.signals.BindingActiveException;
23+
import com.vaadin.signals.Signal;
2224

2325
import static com.vaadin.flow.dom.ElementConstants.STYLE_ALIGN_ITEMS;
2426
import static com.vaadin.flow.dom.ElementConstants.STYLE_ALIGN_SELF;
@@ -100,13 +102,19 @@ public interface Style extends Serializable {
100102
/**
101103
* Gets the value of the given style property.
102104
* <p>
103-
* Note that the name should be in camelCase and not dash-separated, i.e.
104-
* use "fontFamily" and not "font-family"
105+
* Note that the name should be in camelCase and not dash-separated, i.e.,
106+
* use "fontFamily" and not "font-family".
107+
* <p>
108+
* When a style property is bound to a signal with
109+
* {@link #bind(String, Signal)}, this method returns the value of the
110+
* latest signal applied for the given style property name while the element
111+
* was in the attached state.
105112
*
106113
* @param name
107114
* the style property name as camelCase, not <code>null</code>
108115
* @return the style property value, or <code>null</code> if the style
109116
* property has not been set
117+
* @see #bind(String, Signal)
110118
*/
111119
String get(String name);
112120

@@ -115,13 +123,17 @@ public interface Style extends Serializable {
115123
* <p>
116124
* Both camelCased (e.g. <code>fontFamily</code>) and dash-separated (e.g.
117125
* <code>font-family</code> versions are supported.
126+
* <p>
127+
* While a signal binding for a specific style name is active, any attempt
128+
* to manually set that same style throws a {@link BindingActiveException}.
118129
*
119130
* @param name
120131
* the style property name as camelCase, not <code>null</code>
121132
* @param value
122133
* the style property value (if <code>null</code>, the property
123134
* will be removed)
124135
* @return this style instance
136+
* @see #bind(String, Signal)
125137
*/
126138
Style set(String name, String value);
127139

@@ -130,17 +142,26 @@ public interface Style extends Serializable {
130142
* <p>
131143
* Both camelCased (e.g. <code>fontFamily</code>) and dash-separated (e.g.
132144
* <code>font-family</code> versions are supported.
145+
* <p>
146+
* While a signal binding for a specific style name is active, any attempt
147+
* to manually remove that same style throws a
148+
* {@link BindingActiveException}.
133149
*
134150
* @param name
135151
* the style property name as camelCase, not <code>null</code>
136152
* @return this style instance
153+
* @see #bind(String, Signal)
137154
*/
138155
Style remove(String name);
139156

140157
/**
141158
* Removes all set style properties.
159+
* <p>
160+
* This method silently clears all style signal bindings (unsubscribe and
161+
* forget recorded values) in addition to clearing style values.
142162
*
143163
* @return this style instance
164+
* @see #bind(String, Signal)
144165
*/
145166
Style clear();
146167

@@ -164,11 +185,56 @@ public interface Style extends Serializable {
164185
* Note that this always returns the name as camelCased, e.g.
165186
* <code>fontFamily</code> even if it has been set as dash-separated
166187
* (<code>font-family</code>).
188+
* <p>
189+
* Includes names of the style properties bound with the signals while the
190+
* element was in the attached state.
167191
*
168192
* @return a stream of defined style property names
193+
* @see #bind(String, Signal)
169194
*/
170195
Stream<String> getNames();
171196

197+
/**
198+
* Binds the given style property to the provided string signal and keeps
199+
* the style property value synchronized with the signal.
200+
* <p>
201+
* Passing {@code null} as the {@code signal} removes any existing binding
202+
* for the given style property. When unbinding, the current presence of the
203+
* style property is left unchanged.
204+
* <p>
205+
* When a binding is in place, the style signal mirrors
206+
* {@code signal.value()}. If the signal value is {@code null}, the style
207+
* property is removed; otherwise it is set to the string value.
208+
* <p>
209+
* The binding effect is active only while the owner element is in the
210+
* attached state. While the owner is in the detached state, updates from
211+
* the signal have no effect.
212+
* <p>
213+
* While a binding for a specific style name is active, any attempt to bind
214+
* another signal for the same name throws a {@link BindingActiveException}.
215+
* <p>
216+
* Name handling follows the same rules as {@link #set(String, String)}:
217+
* both camelCase and dash-separated names are supported and normalized in
218+
* the same way.
219+
*
220+
* @param name
221+
* the style property name, not {@code null}
222+
* @param signal
223+
* the signal that provides the style signal; {@code null}
224+
* removes an existing binding for the given name
225+
* @return this style instance
226+
* @throws BindingActiveException
227+
* thrown when there is already an existing binding
228+
* @see #set(String, String)
229+
* @see #remove(String)
230+
* @see #clear()
231+
* @see #get(String)
232+
* @see #getNames()
233+
*
234+
* @since 25.0
235+
*/
236+
Style bind(String name, Signal<String> signal);
237+
172238
/**
173239
* Sets the <code>background</code> property.
174240
*

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717

1818
import java.util.stream.Stream;
1919

20+
import com.vaadin.flow.dom.Element;
2021
import com.vaadin.flow.dom.ElementUtil;
2122
import com.vaadin.flow.dom.Style;
2223
import com.vaadin.flow.dom.StyleUtil;
2324
import com.vaadin.flow.internal.nodefeature.ElementStylePropertyMap;
25+
import com.vaadin.signals.BindingActiveException;
26+
import com.vaadin.signals.Signal;
2427

2528
/**
2629
* Implementation of {@link Style} for {@link BasicElementStateProvider}.
@@ -32,7 +35,7 @@
3235
*/
3336
public class BasicElementStyle implements Style {
3437

35-
private ElementStylePropertyMap propertyMap;
38+
private final ElementStylePropertyMap propertyMap;
3639

3740
/**
3841
* Creates an instance connected to the given map.
@@ -47,22 +50,30 @@ public BasicElementStyle(ElementStylePropertyMap propertyMap) {
4750
@Override
4851
public Style set(String name, String value) {
4952
ElementUtil.validateStylePropertyName(name);
53+
String attribute = StyleUtil.stylePropertyToAttribute(name);
54+
if (propertyMap.hasSignal(attribute)) {
55+
throw new BindingActiveException(
56+
"Style '" + name + "' is already bound to a signal");
57+
}
5058
if (value == null) {
5159
return this.remove(name);
5260
}
5361
String trimmedValue = value.trim();
5462
ElementUtil.validateStylePropertyValue(trimmedValue);
5563

56-
propertyMap.setProperty(StyleUtil.stylePropertyToAttribute(name),
57-
trimmedValue, true);
64+
propertyMap.setProperty(attribute, trimmedValue, true);
5865
return this;
5966
}
6067

6168
@Override
6269
public Style remove(String name) {
6370
ElementUtil.validateStylePropertyName(name);
64-
65-
propertyMap.removeProperty(StyleUtil.stylePropertyToAttribute(name));
71+
String attribute = StyleUtil.stylePropertyToAttribute(name);
72+
if (propertyMap.hasSignal(attribute)) {
73+
throw new BindingActiveException(
74+
"Style '" + name + "' is already bound to a signal");
75+
}
76+
propertyMap.removeProperty(attribute);
6677
return this;
6778
}
6879

@@ -85,6 +96,15 @@ public Stream<String> getNames() {
8596
return propertyMap.getPropertyNames();
8697
}
8798

99+
@Override
100+
public Style bind(String name, Signal<String> signal) {
101+
ElementUtil.validateStylePropertyName(name);
102+
String attribute = StyleUtil.stylePropertyToAttribute(name);
103+
Element owner = Element.get(propertyMap.getNode());
104+
propertyMap.bindSignal(owner, attribute, signal);
105+
return this;
106+
}
107+
88108
@Override
89109
public boolean has(String name) {
90110
return propertyMap

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.stream.Stream;
1919

2020
import com.vaadin.flow.dom.Style;
21+
import com.vaadin.signals.Signal;
2122

2223
/**
2324
* A style implementation which is empty and immutable.
@@ -60,4 +61,15 @@ public boolean has(String name) {
6061
public Stream<String> getNames() {
6162
return Stream.empty();
6263
}
64+
65+
/**
66+
* {@inheritDoc}
67+
* <p>
68+
* Immutable style implementation does not support binding a {@link Signal}
69+
* to a style property,
70+
*/
71+
@Override
72+
public Style bind(String name, Signal<String> signal) {
73+
throw new UnsupportedOperationException(CANT_MODIFY_MESSAGE);
74+
}
6375
}

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
import java.io.Serializable;
1919
import java.util.stream.Stream;
2020

21+
import com.vaadin.flow.dom.Element;
22+
import com.vaadin.flow.dom.ElementEffect;
2123
import com.vaadin.flow.internal.JacksonCodec;
2224
import com.vaadin.flow.internal.ReflectTools;
2325
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;
2429

2530
/**
2631
* Abstract class to be used as a parent for node maps which supports setting
@@ -151,4 +156,58 @@ public void updateFromClient(String key, Serializable value) {
151156
super.updateFromClient(key, value);
152157
}
153158
}
159+
160+
/**
161+
* Binds the given signal to the given property. <code>null</code> signal
162+
* unbinds existing binding.
163+
*
164+
* @param owner
165+
* the element owning the property, not <code>null</code>
166+
* @param name
167+
* the name of the property
168+
* @param signal
169+
* the signal to bind or <code>null</code> to unbind any existing
170+
* binding
171+
* @throws com.vaadin.signals.BindingActiveException
172+
* thrown when there is already an existing binding for the
173+
* given property
174+
*/
175+
public void bindSignal(Element owner, String name, Signal<?> signal) {
176+
SignalBinding previousSignalBinding;
177+
if (super.get(name) instanceof SignalBinding binding) {
178+
previousSignalBinding = binding;
179+
} else {
180+
previousSignalBinding = null;
181+
}
182+
if (signal != null && hasSignal(name)) {
183+
throw new BindingActiveException();
184+
}
185+
Registration registration = signal != null
186+
? ElementEffect.bind(owner, signal,
187+
(element, value) -> setPropertyFromSignal(name, value))
188+
: null;
189+
if (signal == null && previousSignalBinding != null) {
190+
if (previousSignalBinding.registration() != null) {
191+
previousSignalBinding.registration().remove();
192+
}
193+
// revert to plain stored value (may be null)
194+
put(name, get(name), false);
195+
} else {
196+
put(name, new SignalBinding(signal, registration, get(name)),
197+
false);
198+
}
199+
}
200+
201+
/**
202+
* Applies a value coming from a signal to the property while preserving an
203+
* existing binding.
204+
*
205+
* @param name
206+
* the property name
207+
* @param value
208+
* the value to apply; <code>null</code> removes the property on
209+
* the client but keeps the binding and last-applied value as
210+
* <code>null</code>
211+
*/
212+
protected abstract void setPropertyFromSignal(String name, Object value);
154213
}

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

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,12 @@
3232
import tools.jackson.databind.node.BaseJsonNode;
3333

3434
import com.vaadin.flow.dom.Element;
35-
import com.vaadin.flow.dom.ElementEffect;
3635
import com.vaadin.flow.dom.PropertyChangeEvent;
3736
import com.vaadin.flow.dom.PropertyChangeListener;
3837
import com.vaadin.flow.function.SerializablePredicate;
3938
import com.vaadin.flow.internal.JacksonUtils;
4039
import com.vaadin.flow.internal.StateNode;
4140
import com.vaadin.flow.shared.Registration;
42-
import com.vaadin.signals.BindingActiveException;
43-
import com.vaadin.signals.Signal;
4441
import com.vaadin.signals.ValueSignal;
4542

4643
/**
@@ -120,46 +117,6 @@ public void setProperty(String name, Serializable value) {
120117
setProperty(name, value, true);
121118
}
122119

123-
/**
124-
* Binds the given signal to the given property. <code>null</code> signal
125-
* unbinds existing binding.
126-
*
127-
* @param owner
128-
* the element owning the property, not <code>null</code>
129-
* @param name
130-
* the name of the property
131-
* @param signal
132-
* the signal to bind or <code>null</code> to unbind any existing
133-
* binding
134-
* @throws com.vaadin.signals.BindingActiveException
135-
* thrown when there is already an existing binding for the
136-
* given property
137-
*/
138-
public void bindSignal(Element owner, String name, Signal<?> signal) {
139-
SignalBinding previousSignalBinding;
140-
if (super.get(name) instanceof SignalBinding binding) {
141-
previousSignalBinding = binding;
142-
} else {
143-
previousSignalBinding = null;
144-
}
145-
if (signal != null && hasSignal(name)) {
146-
throw new BindingActiveException();
147-
}
148-
Registration registration = signal != null
149-
? ElementEffect.bind(owner, signal,
150-
(element, value) -> setPropertyFromSignal(name, value))
151-
: null;
152-
if (signal == null && previousSignalBinding != null) {
153-
if (previousSignalBinding.registration() != null) {
154-
previousSignalBinding.registration().remove();
155-
}
156-
put(name, get(name), false);
157-
} else {
158-
put(name, new SignalBinding(signal, registration, get(name)),
159-
false);
160-
}
161-
}
162-
163120
@Override
164121
protected Serializable get(String key) {
165122
Serializable value = super.get(key);
@@ -170,6 +127,7 @@ protected Serializable get(String key) {
170127
}
171128
}
172129

130+
@Override
173131
public void setPropertyFromSignal(String name, Object value) {
174132
assert !forbiddenProperties.contains(name)
175133
: "Forbidden property name: " + name;

0 commit comments

Comments
 (0)