Skip to content

Commit faafb83

Browse files
authored
feat: Add preventScroll and focusVisible support to Focusable (#22580)
Add FocusOption... to focus so you can do e.g. focus(FocusOption.VISIBLE) or focus(FocusOption.NOT_VISIBLE, PreventScroll.ENABLED) Fixes #22078
1 parent ee5139e commit faafb83

File tree

3 files changed

+353
-8
lines changed

3 files changed

+353
-8
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component;
17+
18+
import java.io.Serializable;
19+
20+
import tools.jackson.databind.node.ObjectNode;
21+
22+
import com.vaadin.flow.internal.JacksonUtils;
23+
24+
/**
25+
* Marker interface for focus options.
26+
* <p>
27+
* Implementations of this interface can be passed to
28+
* {@link Focusable#focus(FocusOption...)}.
29+
* <p>
30+
* See <a href=
31+
* "https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus">HTMLElement.focus()</a>
32+
* for more information.
33+
*/
34+
public interface FocusOption extends Serializable {
35+
36+
/**
37+
* Builds an ObjectNode containing the focus options for use with the
38+
* browser's focus() method.
39+
* <p>
40+
* This method extracts FocusVisible and PreventScroll options from the
41+
* varargs and builds a JSON object compatible with the browser's
42+
* HTMLElement.focus() API. Returns null if all options are at their default
43+
* values.
44+
*
45+
* @param options
46+
* zero or more focus options
47+
* @return an ObjectNode with the focus options, or null if all options are
48+
* default
49+
*/
50+
static ObjectNode buildOptions(FocusOption... options) {
51+
// Extract options from varargs
52+
FocusVisible focusVisible = FocusVisible.DEFAULT;
53+
PreventScroll preventScroll = PreventScroll.DEFAULT;
54+
55+
for (FocusOption option : options) {
56+
if (option instanceof FocusVisible) {
57+
focusVisible = (FocusVisible) option;
58+
} else if (option instanceof PreventScroll) {
59+
preventScroll = (PreventScroll) option;
60+
}
61+
}
62+
63+
// Build options object if any non-default values are specified
64+
if (preventScroll == PreventScroll.DEFAULT
65+
&& focusVisible == FocusVisible.DEFAULT) {
66+
return null;
67+
}
68+
69+
ObjectNode json = JacksonUtils.createObjectNode();
70+
71+
if (preventScroll != PreventScroll.DEFAULT) {
72+
json.put("preventScroll", preventScroll == PreventScroll.ENABLED);
73+
}
74+
75+
if (focusVisible != FocusVisible.DEFAULT) {
76+
json.put("focusVisible", focusVisible == FocusVisible.VISIBLE);
77+
}
78+
79+
return json;
80+
}
81+
82+
/**
83+
* Focus visibility option for focus operations.
84+
* <p>
85+
* Controls whether the browser should provide visible indication (focus
86+
* ring) that an element is focused.
87+
*/
88+
enum FocusVisible implements FocusOption {
89+
/**
90+
* Browser decides based on accessibility heuristics (default behavior).
91+
* <p>
92+
* When this option is used, the focusVisible property is not included
93+
* in the options passed to the browser, allowing the browser to
94+
* determine whether to show a focus ring based on how the focus was
95+
* triggered (e.g., keyboard vs mouse).
96+
*/
97+
DEFAULT,
98+
99+
/**
100+
* Force focus ring to be visible.
101+
* <p>
102+
* Use this to ensure a visible focus indicator is shown, which can
103+
* improve accessibility.
104+
*/
105+
VISIBLE,
106+
107+
/**
108+
* Force focus ring to NOT be visible.
109+
* <p>
110+
* Use this to prevent the focus ring from being shown. Use with caution
111+
* as this may impact accessibility.
112+
*/
113+
NOT_VISIBLE
114+
}
115+
116+
/**
117+
* Scroll prevention option for focus operations.
118+
* <p>
119+
* Controls whether the browser should scroll the document to bring the
120+
* newly-focused element into view.
121+
*/
122+
enum PreventScroll implements FocusOption {
123+
/**
124+
* Browser decides (default behavior is to scroll the element into
125+
* view).
126+
* <p>
127+
* When this option is used, the preventScroll property is not included
128+
* in the options passed to the browser, allowing the browser to use its
129+
* default behavior (which is to scroll).
130+
*/
131+
DEFAULT,
132+
133+
/**
134+
* Prevent scrolling when focusing the element.
135+
* <p>
136+
* Use this when you want to focus an element without changing the
137+
* current scroll position.
138+
*/
139+
ENABLED,
140+
141+
/**
142+
* Allow scrolling when focusing the element (browser default).
143+
* <p>
144+
* This explicitly enables the default browser behavior of scrolling the
145+
* element into view when focused.
146+
*/
147+
DISABLED
148+
}
149+
}

flow-server/src/main/java/com/vaadin/flow/component/Focusable.java

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package com.vaadin.flow.component;
1717

18+
import tools.jackson.databind.node.ObjectNode;
19+
1820
import com.vaadin.flow.dom.Element;
1921

2022
/**
@@ -100,20 +102,48 @@ default int getTabIndex() {
100102
/**
101103
* Calls the <code>focus</code> function at the client, making the component
102104
* keyboard focused.
105+
* <p>
106+
* This method can be called with no arguments for default browser behavior,
107+
* or with one or more {@link FocusOption} values to control focus behavior:
108+
* <ul>
109+
* <li>{@link FocusOption.FocusVisible} - controls whether the focus ring is
110+
* visible</li>
111+
* <li>{@link FocusOption.PreventScroll} - controls whether the browser
112+
* scrolls to the element</li>
113+
* </ul>
114+
* <p>
115+
* Examples:
103116
*
117+
* <pre>
118+
* component.focus(); // Default behavior
119+
* component.focus(PreventScroll.ENABLED); // Focus without scrolling
120+
* component.focus(FocusVisible.VISIBLE, PreventScroll.ENABLED); // Both
121+
* // options
122+
* </pre>
123+
* <p>
124+
* Note: The {@code focusVisible} option is experimental and may not be
125+
* supported in all browsers. When not specified, the browser decides
126+
* whether to show the focus ring based on accessibility heuristics (e.g.,
127+
* keyboard vs mouse interaction).
128+
*
129+
* @param options
130+
* zero or more focus options
104131
* @see <a href=
105132
* "https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus">focus
106133
* at MDN</a>
107134
*/
108-
default void focus() {
109-
/*
110-
* Use setTimeout to call the focus function only after the element is
111-
* attached, and after the initial rendering cycle, so webcomponents can
112-
* be ready by the time when the function is called.
113-
*/
135+
default void focus(FocusOption... options) {
114136
Element element = getElement();
115-
// Using $0 since "this" won't work inside the function
116-
element.executeJs("setTimeout(function(){$0.focus()},0)", element);
137+
ObjectNode json = FocusOption.buildOptions(options);
138+
139+
if (json == null) {
140+
// No options, call focus() without arguments
141+
element.executeJs("setTimeout(function(){$0.focus()},0)", element);
142+
} else {
143+
// Call focus with options object passed as parameter
144+
element.executeJs("setTimeout(function(){$0.focus($1)},0)", element,
145+
json);
146+
}
117147
}
118148

119149
/**

flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import org.junit.Assert;
2121
import org.junit.Test;
2222

23+
import com.vaadin.flow.component.FocusOption.FocusVisible;
24+
import com.vaadin.flow.component.FocusOption.PreventScroll;
2325
import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
2426
import com.vaadin.tests.util.MockUI;
2527

@@ -78,4 +80,168 @@ private void assertPendingInvocationCount(String message, int expected) {
7880
.dumpPendingJsInvocations();
7981
Assert.assertEquals(message, expected, invocations.size());
8082
}
83+
84+
@Test
85+
public void focus_withFocusVisible_generatesCorrectJS() {
86+
ui.add(component);
87+
component.focus(FocusVisible.VISIBLE);
88+
89+
List<PendingJavaScriptInvocation> invocations = ui
90+
.dumpPendingJsInvocations();
91+
Assert.assertEquals(1, invocations.size());
92+
93+
String expression = invocations.get(0).getInvocation().getExpression();
94+
Assert.assertTrue("Should contain setTimeout wrapper",
95+
expression.contains("setTimeout"));
96+
Assert.assertTrue("Should contain focus call with parameter",
97+
expression.contains(".focus($1)"));
98+
99+
// Check the parameters
100+
List<Object> params = invocations.get(0).getInvocation()
101+
.getParameters();
102+
// First param is element, second param is the options object
103+
Assert.assertTrue("Should have at least 2 parameters",
104+
params.size() >= 2);
105+
String paramJson = params.get(1).toString();
106+
Assert.assertTrue("Should set focusVisible to true",
107+
paramJson.contains("\"focusVisible\":true"));
108+
Assert.assertFalse("Should not contain preventScroll",
109+
paramJson.contains("preventScroll"));
110+
}
111+
112+
@Test
113+
public void focus_withFocusNotVisible_generatesCorrectJS() {
114+
ui.add(component);
115+
component.focus(FocusVisible.NOT_VISIBLE);
116+
117+
List<PendingJavaScriptInvocation> invocations = ui
118+
.dumpPendingJsInvocations();
119+
Assert.assertEquals(1, invocations.size());
120+
121+
String expression = invocations.get(0).getInvocation().getExpression();
122+
Assert.assertTrue("Should contain setTimeout wrapper",
123+
expression.contains("setTimeout"));
124+
Assert.assertTrue("Should contain focus call with parameter",
125+
expression.contains(".focus($1)"));
126+
127+
// Check the parameters
128+
List<Object> params = invocations.get(0).getInvocation()
129+
.getParameters();
130+
// First param is element, second param is the options object
131+
Assert.assertTrue("Should have at least 2 parameters",
132+
params.size() >= 2);
133+
String paramJson = params.get(1).toString();
134+
Assert.assertTrue("Should set focusVisible to false",
135+
paramJson.contains("\"focusVisible\":false"));
136+
}
137+
138+
@Test
139+
public void focus_withPreventScrollEnabled_generatesCorrectJS() {
140+
ui.add(component);
141+
component.focus(PreventScroll.ENABLED);
142+
143+
List<PendingJavaScriptInvocation> invocations = ui
144+
.dumpPendingJsInvocations();
145+
Assert.assertEquals(1, invocations.size());
146+
147+
String expression = invocations.get(0).getInvocation().getExpression();
148+
Assert.assertTrue("Should contain setTimeout wrapper",
149+
expression.contains("setTimeout"));
150+
Assert.assertTrue("Should contain focus call with parameter",
151+
expression.contains(".focus($1)"));
152+
153+
// Check the parameters
154+
List<Object> params = invocations.get(0).getInvocation()
155+
.getParameters();
156+
// First param is element, second param is the options object
157+
Assert.assertTrue("Should have at least 2 parameters",
158+
params.size() >= 2);
159+
String paramJson = params.get(1).toString();
160+
Assert.assertTrue("Should set preventScroll to true",
161+
paramJson.contains("\"preventScroll\":true"));
162+
Assert.assertFalse("Should not contain focusVisible",
163+
paramJson.contains("focusVisible"));
164+
}
165+
166+
@Test
167+
public void focus_withPreventScrollDisabled_generatesCorrectJS() {
168+
ui.add(component);
169+
component.focus(PreventScroll.DISABLED);
170+
171+
List<PendingJavaScriptInvocation> invocations = ui
172+
.dumpPendingJsInvocations();
173+
Assert.assertEquals(1, invocations.size());
174+
175+
String expression = invocations.get(0).getInvocation().getExpression();
176+
Assert.assertTrue("Should contain setTimeout wrapper",
177+
expression.contains("setTimeout"));
178+
Assert.assertTrue("Should contain focus call with parameter",
179+
expression.contains(".focus($1)"));
180+
181+
// Check the parameters
182+
List<Object> params = invocations.get(0).getInvocation()
183+
.getParameters();
184+
// First param is element, second param is the options object
185+
Assert.assertTrue("Should have at least 2 parameters",
186+
params.size() >= 2);
187+
String paramJson = params.get(1).toString();
188+
Assert.assertTrue("Should set preventScroll to false",
189+
paramJson.contains("\"preventScroll\":false"));
190+
}
191+
192+
@Test
193+
public void focus_withBothOptions_generatesCorrectJS() {
194+
ui.add(component);
195+
component.focus(FocusVisible.VISIBLE, PreventScroll.ENABLED);
196+
197+
List<PendingJavaScriptInvocation> invocations = ui
198+
.dumpPendingJsInvocations();
199+
Assert.assertEquals(1, invocations.size());
200+
201+
String expression = invocations.get(0).getInvocation().getExpression();
202+
Assert.assertTrue("Should contain setTimeout wrapper",
203+
expression.contains("setTimeout"));
204+
Assert.assertTrue("Should contain focus call with parameter",
205+
expression.contains(".focus($1)"));
206+
207+
// Check the parameters
208+
List<Object> params = invocations.get(0).getInvocation()
209+
.getParameters();
210+
// First param is element, second param is the options object
211+
Assert.assertTrue("Should have at least 2 parameters",
212+
params.size() >= 2);
213+
String paramJson = params.get(1).toString();
214+
Assert.assertTrue("Should set preventScroll to true",
215+
paramJson.contains("\"preventScroll\":true"));
216+
Assert.assertTrue("Should set focusVisible to true",
217+
paramJson.contains("\"focusVisible\":true"));
218+
}
219+
220+
@Test
221+
public void focus_withBothOptionsFalse_generatesCorrectJS() {
222+
ui.add(component);
223+
component.focus(FocusVisible.NOT_VISIBLE, PreventScroll.DISABLED);
224+
225+
List<PendingJavaScriptInvocation> invocations = ui
226+
.dumpPendingJsInvocations();
227+
Assert.assertEquals(1, invocations.size());
228+
229+
String expression = invocations.get(0).getInvocation().getExpression();
230+
Assert.assertTrue("Should contain setTimeout wrapper",
231+
expression.contains("setTimeout"));
232+
Assert.assertTrue("Should contain focus call with parameter",
233+
expression.contains(".focus($1)"));
234+
235+
// Check the parameters
236+
List<Object> params = invocations.get(0).getInvocation()
237+
.getParameters();
238+
// First param is element, second param is the options object
239+
Assert.assertTrue("Should have at least 2 parameters",
240+
params.size() >= 2);
241+
String paramJson = params.get(1).toString();
242+
Assert.assertTrue("Should set preventScroll to false",
243+
paramJson.contains("\"preventScroll\":false"));
244+
Assert.assertTrue("Should set focusVisible to false",
245+
paramJson.contains("\"focusVisible\":false"));
246+
}
81247
}

0 commit comments

Comments
 (0)