diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 61cb614b28a..e0aa5988079 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -6,6 +6,7 @@ import { } from '@vaadin/common-frontend'; import './Geolocation'; import { currentVisibility } from './PageVisibility'; +import './Triggers'; export interface FlowConfig { imports?: () => Promise; diff --git a/flow-client/src/main/frontend/Triggers.ts b/flow-client/src/main/frontend/Triggers.ts new file mode 100644 index 00000000000..13efbe5aeaf --- /dev/null +++ b/flow-client/src/main/frontend/Triggers.ts @@ -0,0 +1,411 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ + +/** + * Snapshot of trigger bindings for one host element, sent by the server. + * Shape mirrors com.vaadin.flow.component.trigger.internal.TriggerSupport + * #buildSnapshot. + */ +interface Snapshot { + triggers: Record }>; + actions: Record }>; + arguments: Record }>; + bindings: Array<{ trigger: number; actions: number[] }>; +} + +/** + * Resolves an argument's current value at the moment a trigger fires. + */ +type ArgumentResolver = (id: number) => unknown; + +interface TriggerInstance { + uninstall(): void; +} + +type TriggerFactory = ( + host: HTMLElement, + config: Record, + extraElements: HTMLElement[], + fire: () => void +) => TriggerInstance; + +interface ActionInstance { + run(resolveArgument: ArgumentResolver): void; +} + +type ServerNotify = (...args: unknown[]) => void; + +type ActionFactory = ( + config: Record, + extraElements: HTMLElement[], + notifyServer: ServerNotify +) => ActionInstance; + +interface ArgumentInstance { + read(): unknown; +} + +type ArgumentFactory = (config: Record, extraElements: HTMLElement[]) => ArgumentInstance; + +const triggerFactories = new Map(); +const actionFactories = new Map(); +const argumentFactories = new Map(); + +interface Installation { + triggers: TriggerInstance[]; +} + +const installations = new WeakMap(); + +function disposeInstallation(host: HTMLElement): void { + const existing = installations.get(host); + if (existing) { + for (const t of existing.triggers) { + try { + t.uninstall(); + } catch (e) { + console.debug('trigger uninstall failed', e); + } + } + installations.delete(host); + } +} + +function resolveExtraElements(maybeRefs: unknown): HTMLElement[] { + if (!Array.isArray(maybeRefs)) { + return []; + } + return maybeRefs.filter((e): e is HTMLElement => e instanceof HTMLElement); +} + +type MirrorChannel = (...args: unknown[]) => void; + +function bind(host: HTMLElement, snapshot: Snapshot, extraRefs?: unknown, channel?: MirrorChannel): void { + if (!(host instanceof HTMLElement)) { + return; + } + disposeInstallation(host); + + const extras = resolveExtraElements(extraRefs); + const mirror: MirrorChannel = typeof channel === 'function' ? channel : () => undefined; + + // Lazily instantiate actions and arguments so a trigger that never fires + // doesn't pay for its actions. + const actionCache = new Map(); + const argumentCache = new Map(); + + function getAction(id: number): ActionInstance | null { + const cached = actionCache.get(id); + if (cached) { + return cached; + } + const def = snapshot.actions[String(id)]; + if (!def) { + console.debug(`trigger action id ${id} not found in snapshot`); + return null; + } + const factory = actionFactories.get(def.type); + if (!factory) { + console.debug(`no client factory registered for action type ${def.type}`); + return null; + } + const notify: ServerNotify = (...args) => mirror(id, ...args); + const instance = factory(def.config, extras, notify); + actionCache.set(id, instance); + return instance; + } + + function getArgument(id: number): ArgumentInstance | null { + const cached = argumentCache.get(id); + if (cached) { + return cached; + } + const def = snapshot.arguments[String(id)]; + if (!def) { + console.debug(`trigger argument id ${id} not found in snapshot`); + return null; + } + const factory = argumentFactories.get(def.type); + if (!factory) { + console.debug(`no client factory registered for argument type ${def.type}`); + return null; + } + const instance = factory(def.config, extras); + argumentCache.set(id, instance); + return instance; + } + + const resolveArgument: ArgumentResolver = (id) => getArgument(id)?.read(); + + const installedTriggers: TriggerInstance[] = []; + + for (const [idStr, def] of Object.entries(snapshot.triggers)) { + const id = Number(idStr); + const factory = triggerFactories.get(def.type); + if (!factory) { + console.debug(`no client factory registered for trigger type ${def.type}`); + continue; + } + const fire = () => { + for (const binding of snapshot.bindings) { + if (binding.trigger !== id) { + continue; + } + for (const actionId of binding.actions) { + const action = getAction(actionId); + if (!action) { + continue; + } + try { + action.run(resolveArgument); + } catch (e) { + console.debug(`trigger action ${actionId} threw`, e); + } + } + } + }; + try { + installedTriggers.push(factory(host, def.config, extras, fire)); + } catch (e) { + console.debug(`trigger ${id} (${def.type}) install threw`, e); + } + } + + installations.set(host, { triggers: installedTriggers }); +} + +function unbind(host: HTMLElement): void { + disposeInstallation(host); +} + +// --- Built-in factories -------------------------------------------------- + +triggerFactories.set('flow:click', (host, _config, _extras, fire) => { + const listener = () => fire(); + host.addEventListener('click', listener); + return { + uninstall() { + host.removeEventListener('click', listener); + } + }; +}); + +triggerFactories.set('flow:js', (host, config, _extras, fire) => { + const expression = String(config.expression ?? ''); + let cleanup: unknown; + try { + const setup = new Function('trigger', expression); + cleanup = setup.call(host, fire); + } catch (e) { + console.debug('flow:js trigger setup threw', e); + } + return { + uninstall() { + if (typeof cleanup === 'function') { + try { + (cleanup as () => void)(); + } catch (e) { + console.debug('flow:js trigger cleanup threw', e); + } + } + } + }; +}); + +triggerFactories.set('flow:shortcut', (host, config, _extras, fire) => { + const key = String(config.key ?? ''); + const modifierList = Array.isArray(config.modifiers) ? (config.modifiers as unknown[]).map(String) : []; + const wantCtrl = modifierList.includes('Control'); + const wantAlt = modifierList.includes('Alt') || modifierList.includes('AltGraph'); + const wantShift = modifierList.includes('Shift'); + const wantMeta = modifierList.includes('Meta'); + const listener = (e: Event) => { + const ke = e as KeyboardEvent; + if (ke.key !== key) return; + if (ke.ctrlKey !== wantCtrl) return; + if (ke.altKey !== wantAlt) return; + if (ke.shiftKey !== wantShift) return; + if (ke.metaKey !== wantMeta) return; + // Don't preventDefault by default — shortcuts in form fields may want + // the keystroke to keep flowing. Application code can wrap a JsTrigger + // if it needs that behaviour. + fire(); + }; + // Shortcuts may be defined on a "scope" host that the user does not + // expect to focus. Listening on the host with capture so the shortcut + // also fires when focus is inside a descendant input. + host.addEventListener('keydown', listener, true); + return { + uninstall() { + host.removeEventListener('keydown', listener, true); + } + }; +}); + +argumentFactories.set('flow:js', (config) => { + const expression = String(config.expression ?? ''); + let read: () => unknown; + try { + read = new Function(expression) as () => unknown; + } catch (e) { + console.debug('flow:js argument compile threw', e); + read = () => undefined; + } + return { + read() { + try { + return read(); + } catch (e) { + console.debug('flow:js argument read threw', e); + return undefined; + } + } + }; +}); + +argumentFactories.set('flow:signal-value', (config) => { + const value = config.value; + return { + read() { + return value; + } + }; +}); + +argumentFactories.set('flow:property', (config, extras) => { + const elementIndex = Number(config.element ?? 0); + const property = String(config.property ?? ''); + return { + read() { + const target = elementIndex === 0 ? null : extras[elementIndex - 1]; + // elementIndex 0 means "host"; not supported for property arguments in + // v0 (arguments aren't bound to the host element directly). + if (!target) { + return undefined; + } + return (target as unknown as Record)[property]; + } + }; +}); + +actionFactories.set('flow:js', (config) => { + const expression = String(config.expression ?? ''); + const argumentIds = Array.isArray(config.arguments) ? (config.arguments as unknown[]).map(Number) : []; + let fn: (argument: (i: number) => unknown) => unknown; + try { + fn = new Function('argument', expression) as typeof fn; + } catch (e) { + console.debug('flow:js action compile threw', e); + fn = () => undefined; + } + return { + run(resolveArgument) { + const argument = (i: number) => { + if (i < 0 || i >= argumentIds.length) { + return undefined; + } + return resolveArgument(argumentIds[i]); + }; + try { + fn(argument); + } catch (e) { + console.debug('flow:js action run threw', e); + } + } + }; +}); + +actionFactories.set('flow:server-callback', (_config, _extras, notifyServer) => { + return { + run() { + notifyServer(); + } + }; +}); + +actionFactories.set('flow:click', (config, extras) => { + const elementIndex = Number(config.element ?? 0); + return { + run() { + const target = elementIndex === 0 ? null : extras[elementIndex - 1]; + if (target && typeof (target as HTMLElement).click === 'function') { + (target as HTMLElement).click(); + } + } + }; +}); + +actionFactories.set('flow:set-enabled', (config, extras, notifyServer) => { + const elementIndex = Number(config.element ?? 0); + const enabled = Boolean(config.enabled); + const mirror = Boolean(config.mirror); + return { + run() { + const target = elementIndex === 0 ? null : extras[elementIndex - 1]; + if (!target) { + return; + } + if (enabled) { + target.removeAttribute('disabled'); + } else { + target.setAttribute('disabled', ''); + } + if (mirror) { + notifyServer(); + } + } + }; +}); + +actionFactories.set('flow:clipboard-copy', (config) => { + const textId = Number(config.text); + return { + run(resolveArgument) { + const text = resolveArgument(textId); + const clipboard = (navigator as Navigator & { clipboard?: Clipboard }).clipboard; + if (!clipboard || typeof clipboard.writeText !== 'function') { + console.debug('navigator.clipboard.writeText unavailable'); + return; + } + void clipboard.writeText(text == null ? '' : String(text)).catch((e) => { + console.debug('clipboard.writeText failed', e); + }); + } + }; +}); + +// --- Public registry on window.Vaadin.Flow.triggers ---------------------- + +const $wnd = window as unknown as { Vaadin?: { Flow?: Record } }; +$wnd.Vaadin ??= {}; +$wnd.Vaadin.Flow ??= {}; +$wnd.Vaadin.Flow.triggers = { + bind, + unbind, + registerTrigger(typeId: string, factory: TriggerFactory): void { + triggerFactories.set(typeId, factory); + }, + registerAction(typeId: string, factory: ActionFactory): void { + actionFactories.set(typeId, factory); + }, + registerArgument(typeId: string, factory: ArgumentFactory): void { + argumentFactories.set(typeId, factory); + } +}; + +// Ensures this file is emitted as an ES module by TypeScript so Vite +// loads it correctly. +export {}; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractAction.java new file mode 100644 index 00000000000..07bdddb33a0 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractAction.java @@ -0,0 +1,91 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Base class for {@link Action} implementations. + *

+ * Subclasses identify themselves with a namespaced type id + * ({@code "flow:clipboard-copy"}, {@code "myapp:show-toast"}, …) which must + * match a factory registered against {@code window.Vaadin.Flow.triggers} on the + * client side. Subclasses override {@link #buildClientConfig} to ship + * configuration with the action; if the action has a server-observable effect + * that should stay in sync with what just ran in the browser, they also + * override {@link #applyServerSideEffect()}. + */ +public abstract class AbstractAction implements Action { + + private final String typeId; + + /** + * Creates a new action with the given namespaced type id. + * + * @param typeId + * type id matching a client factory, not {@code null} + */ + protected AbstractAction(String typeId) { + this.typeId = Objects.requireNonNull(typeId); + } + + /** + * The namespaced type id of this action. + * + * @return the type id, never {@code null} + */ + public final String getTypeId() { + return typeId; + } + + /** + * Produces the JSON configuration this action sends to the client. Default + * is an empty object; override to add type-specific options. + *

+ * Subclasses encode argument references by calling + * {@link ConfigContext#registerArgument(Argument)} and element references + * by calling + * {@link ConfigContext#referenceElement(com.vaadin.flow.dom.Element)}. + * Public so the internal framework can read the config without reflection; + * subclasses just override. + * + * @param context + * the resolver for referenced elements and arguments, not + * {@code null} + * @return a Jackson {@link ObjectNode}, never {@code null} + */ + public ObjectNode buildClientConfig(ConfigContext context) { + return JacksonUtils.createObjectNode(); + } + + /** + * Mirrors the client-side effect on the server. Called on the UI thread, at + * the start of the same server cycle that processes the triggering DOM + * event, before any user-attached event listeners run, so that listener + * code observes the post-action state. + *

+ * Default is a no-op. Subclasses with a server-observable effect (e.g. + * {@code SetEnabledAction}) override this. + */ + public void applyServerSideEffect() { + // No server-side mirror by default. + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractArgument.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractArgument.java new file mode 100644 index 00000000000..95491262876 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractArgument.java @@ -0,0 +1,89 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Base class for {@link Argument} implementations. + *

+ * Subclasses identify themselves with a namespaced type id + * ({@code "flow:property"}, {@code "myapp:caret-offset"}, …) which must match a + * factory registered against {@code window.Vaadin.Flow.triggers} on the client + * side. Subclasses override {@link #buildClientConfig} when they need to ship + * configuration with the argument. + * + * @param + * the runtime type of the value produced + */ +public abstract class AbstractArgument implements Argument { + + private final String typeId; + private final Class valueType; + + /** + * Creates a new argument. + * + * @param typeId + * namespaced type id matching a client factory, not {@code null} + * @param valueType + * runtime type of the produced value, not {@code null} + */ + protected AbstractArgument(String typeId, Class valueType) { + this.typeId = Objects.requireNonNull(typeId); + this.valueType = Objects.requireNonNull(valueType); + } + + /** + * The namespaced type id of this argument. + * + * @return the type id, never {@code null} + */ + public final String getTypeId() { + return typeId; + } + + /** + * The runtime type of the value this argument produces. + * + * @return the value type, never {@code null} + */ + public final Class getValueType() { + return valueType; + } + + /** + * Produces the JSON configuration this argument sends to the client. + * Default is an empty object; override to add type-specific options. + *

+ * Subclasses encode element references by calling + * {@link ConfigContext#referenceElement(com.vaadin.flow.dom.Element)}. + * Public so the internal framework can read the config without reflection; + * subclasses just override. + * + * @param context + * the resolver for referenced elements, not {@code null} + * @return a Jackson {@link ObjectNode}, never {@code null} + */ + public ObjectNode buildClientConfig(ConfigContext context) { + return JacksonUtils.createObjectNode(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractTrigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractTrigger.java new file mode 100644 index 00000000000..c203744cc29 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/AbstractTrigger.java @@ -0,0 +1,136 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.component.trigger.internal.ServerCallbackAction; +import com.vaadin.flow.component.trigger.internal.TriggerSupport; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableRunnable; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Base class for {@link Trigger} implementations. + *

+ * Subclasses identify themselves with a namespaced type id + * ({@code "flow:click"}, {@code "myapp:double-tap"}, …) which must match a + * factory registered against {@code window.Vaadin.Flow.triggers} on the client + * side. Subclasses override + * {@link #buildClientConfig(com.vaadin.flow.component.trigger.internal.ConfigContext)} + * when they need to ship extra configuration with the trigger. + */ +public abstract class AbstractTrigger implements Trigger { + + private final String typeId; + private final Element host; + private final transient TriggerSupport support; + private final int triggerId; + + /** + * Creates a new trigger bound to the given host element. + * + * @param typeId + * namespaced type id matching a client factory, not {@code null} + * @param host + * the element the trigger fires on, not {@code null} + */ + protected AbstractTrigger(String typeId, Element host) { + this.typeId = Objects.requireNonNull(typeId); + this.host = Objects.requireNonNull(host); + this.support = TriggerSupport.on(host); + this.triggerId = support.registerTrigger(this); + } + + /** + * Creates a new trigger bound to the given host component's root element. + * + * @param typeId + * namespaced type id matching a client factory, not {@code null} + * @param host + * the component whose root element the trigger fires on, not + * {@code null} + */ + protected AbstractTrigger(String typeId, Component host) { + this(typeId, Objects.requireNonNull(host).getElement()); + } + + /** + * The host element this trigger fires on. + * + * @return the host element, never {@code null} + */ + public final Element getHost() { + return host; + } + + /** + * The namespaced type id of this trigger. + * + * @return the type id, never {@code null} + */ + public final String getTypeId() { + return typeId; + } + + /** + * Internal id for this trigger within its host's {@link TriggerSupport}. + * + * @return the id + */ + public final int getTriggerId() { + return triggerId; + } + + /** + * Produces the JSON configuration this trigger sends to the client. Default + * is an empty object; override to add type-specific options. + *

+ * The {@code context} lets subclasses encode element/argument references by + * id when needed. Public so the internal framework can read the config + * without reflection; subclasses just override. + * + * @param context + * the resolver for referenced elements and arguments, not + * {@code null} + * @return a Jackson {@link ObjectNode}, never {@code null} + */ + public ObjectNode buildClientConfig(ConfigContext context) { + return JacksonUtils.createObjectNode(); + } + + @Override + public final Trigger triggers(Action... actions) { + Objects.requireNonNull(actions); + support.bind(this, actions); + return this; + } + + @Override + public final Trigger triggers(SerializableRunnable serverHandler) { + Objects.requireNonNull(serverHandler); + return triggers(new ServerCallbackAction(serverHandler)); + } + + @Override + public final void remove() { + support.removeTrigger(this); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/Action.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Action.java new file mode 100644 index 00000000000..bfd59539c78 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Action.java @@ -0,0 +1,35 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.io.Serializable; + +/** + * Something that runs on the client when a {@link Trigger} fires. + *

+ * An action may also have a server-side effect that mirrors what just happened + * in the browser (for example, updating a server-side "enabled" flag after the + * client has disabled the element). The mirror is applied during the same + * server cycle that processes the trigger event, before any user-attached DOM + * event listeners run, so listener code sees the post-action state. + *

+ * The same {@code Action} instance may be wired to multiple triggers on the + * same host; the client-side handler runs once per binding. + *

+ * Implementations should extend {@link AbstractAction}. + */ +public interface Action extends Serializable { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/Argument.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Argument.java new file mode 100644 index 00000000000..638e2481b99 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Argument.java @@ -0,0 +1,37 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.io.Serializable; + +/** + * A value produced on the client at the moment a trigger fires, snapshotted and + * passed into the bound {@link Action actions}. + *

+ * Arguments are resolved synchronously inside the trigger's DOM event handler, + * never reactively. Use a {@code SignalArgument} if you need a server-side + * {@link com.vaadin.flow.signals.Signal} to feed the value. + *

+ * The same {@code Argument} instance may be referenced from multiple actions; + * its value is computed once per fire and reused. + *

+ * Implementations should extend {@link AbstractArgument}. + * + * @param + * the runtime type of the value produced + */ +public interface Argument extends Serializable { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickAction.java new file mode 100644 index 00000000000..28ed7f32fc3 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickAction.java @@ -0,0 +1,63 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Calls {@code target.click()} on a target component, dispatching a synthetic + * click event on its root element. Typically used to chain a trigger onto + * another component's click handling — for example, a shortcut that fires the + * same path as a button press. + */ +public class ClickAction extends AbstractAction { + + public static final String TYPE_ID = "flow:click"; + + private final Element target; + + /** + * Creates a click action that clicks the given target component. + * + * @param target + * the component to click, not {@code null} + */ + public ClickAction(Component target) { + super(TYPE_ID); + this.target = Objects.requireNonNull(target).getElement(); + } + + /** + * @return the target element + */ + public Element getTarget() { + return target; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("element", context.referenceElement(target)); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickTrigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickTrigger.java new file mode 100644 index 00000000000..99d964f81fb --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClickTrigger.java @@ -0,0 +1,40 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import com.vaadin.flow.component.Component; + +/** + * Fires when the host component receives a {@code click} DOM event. The bound + * actions run inside the click handler, preserving the browser's user-gesture + * context (so downstream actions may invoke APIs gated on a gesture, such as + * clipboard or fullscreen). + */ +public class ClickTrigger extends AbstractTrigger { + + public static final String TYPE_ID = "flow:click"; + + /** + * Creates a click trigger bound to the given host component's root element. + * + * @param host + * the component whose click event should fire this trigger, not + * {@code null} + */ + public ClickTrigger(Component host) { + super(TYPE_ID, host); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClipboardCopyAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClipboardCopyAction.java new file mode 100644 index 00000000000..7ec80b96ed3 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClipboardCopyAction.java @@ -0,0 +1,70 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Copies a value to the user's clipboard via + * {@code navigator.clipboard.writeText}. + *

+ * The Clipboard API requires the call to happen inside a short-lived user + * gesture (click, key press, …). Bind this action to a {@link Trigger} that + * fires during such a gesture, e.g. {@link ClickTrigger}. + * + *

{@code
+ * Argument value = new PropertyArgument<>(textField, "value",
+ *         String.class);
+ * new ClickTrigger(button).triggers(new ClipboardCopyAction(value));
+ * }
+ */ +public class ClipboardCopyAction extends AbstractAction { + + public static final String TYPE_ID = "flow:clipboard-copy"; + + private final Argument textArgument; + + /** + * Creates a clipboard-copy action that copies the value produced by the + * given argument. + * + * @param textArgument + * the argument supplying the text to copy, not {@code null} + */ + public ClipboardCopyAction(Argument textArgument) { + super(TYPE_ID); + this.textArgument = Objects.requireNonNull(textArgument); + } + + /** + * @return the argument supplying the text + */ + public Argument getTextArgument() { + return textArgument; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("text", context.registerArgument(textArgument)); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsAction.java new file mode 100644 index 00000000000..1b0b1b0b4cf --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsAction.java @@ -0,0 +1,87 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.List; +import java.util.Objects; + +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Action backed by an arbitrary JavaScript expression — the escape hatch for + * cases not covered by a built-in {@link AbstractAction}. + *

+ * The expression runs every time the trigger fires. A single helper is in + * scope: {@code argument(i)} returns the resolved value of the i-th declared + * argument (in the order passed to this constructor). + * + *

{@code
+ * Argument who = new PropertyArgument<>(field, "value", String.class);
+ * new JsAction("alert('Hello ' + argument(0));", who);
+ * }
+ */ +public class JsAction extends AbstractAction { + + public static final String TYPE_ID = "flow:js"; + + private final String expression; + private final List> arguments; + + /** + * Creates a JS-backed action. + * + * @param expression + * the JS source, not {@code null} + * @param arguments + * arguments available to the expression via {@code argument(i)}, + * in the order passed + */ + public JsAction(String expression, Argument... arguments) { + super(TYPE_ID); + this.expression = Objects.requireNonNull(expression); + this.arguments = List.of(arguments); + } + + /** + * @return the JS expression + */ + public String getExpression() { + return expression; + } + + /** + * @return the declared arguments in order + */ + public List> getArguments() { + return arguments; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("expression", expression); + ArrayNode ids = JacksonUtils.createArrayNode(); + for (Argument argument : arguments) { + ids.add(context.registerArgument(argument)); + } + node.set("arguments", ids); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsArgument.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsArgument.java new file mode 100644 index 00000000000..34acedae4b6 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsArgument.java @@ -0,0 +1,73 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Argument backed by an arbitrary JavaScript expression — the escape hatch for + * cases not covered by a built-in {@link AbstractArgument}. + *

+ * The expression runs at the moment the trigger fires and its return value + * becomes the argument. The expression executes in the global scope; use + * {@code document.querySelector(...)} or other DOM globals to reach elements. + * + *

{@code
+ * Argument hostName = new JsArgument<>(String.class,
+ *         "return window.location.hostname;");
+ * }
+ * + * @param + * the runtime type of the produced value + */ +public class JsArgument extends AbstractArgument { + + public static final String TYPE_ID = "flow:js"; + + private final String expression; + + /** + * Creates a JS-backed argument. + * + * @param valueType + * the runtime type, not {@code null} + * @param expression + * the JS source, not {@code null} + */ + public JsArgument(Class valueType, String expression) { + super(TYPE_ID, valueType); + this.expression = Objects.requireNonNull(expression); + } + + /** + * @return the JS expression + */ + public String getExpression() { + return expression; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("expression", expression); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsTrigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsTrigger.java new file mode 100644 index 00000000000..7d2be4e2458 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/JsTrigger.java @@ -0,0 +1,75 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Trigger backed by an arbitrary JavaScript expression — the escape hatch for + * cases not covered by a built-in {@link AbstractTrigger}. + *

+ * The expression is evaluated once during {@code bind} with the host + * component's root element as {@code this} and a single named parameter + * {@code trigger} — a function the expression must call (synchronously, inside + * a DOM event handler) to fire the trigger. The expression may return a cleanup + * function; if it does, the cleanup runs when the trigger is removed or when + * the host is re-bound. + * + *

{@code
+ * new JsTrigger(host, "this.addEventListener('dblclick', trigger);"
+ *         + "return () => this.removeEventListener('dblclick', trigger);")
+ *         .triggers(action);
+ * }
+ */ +public class JsTrigger extends AbstractTrigger { + + public static final String TYPE_ID = "flow:js"; + + private final String expression; + + /** + * Creates a JS-backed trigger on the given host component. + * + * @param host + * the host component, not {@code null} + * @param expression + * the JS source, not {@code null} + */ + public JsTrigger(Component host, String expression) { + super(TYPE_ID, host); + this.expression = Objects.requireNonNull(expression); + } + + /** + * @return the JS expression + */ + public String getExpression() { + return expression; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("expression", expression); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/PropertyArgument.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/PropertyArgument.java new file mode 100644 index 00000000000..b89505eb3d1 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/PropertyArgument.java @@ -0,0 +1,88 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Reads a JavaScript property from a target component's root element at the + * moment a trigger fires. + *

+ * Common targets and properties: + *

    + *
  • {@code TextField.value} → + * {@code new PropertyArgument<>(textField, "value", String.class)} + *
  • {@code Checkbox.checked} → + * {@code new PropertyArgument<>(checkbox, "checked", Boolean.class)} + *
+ * + * @param + * the runtime type of the value produced + */ +public class PropertyArgument extends AbstractArgument { + + public static final String TYPE_ID = "flow:property"; + + private final Element target; + private final String propertyName; + + /** + * Creates a property argument that reads the given JS property from the + * given target component. + * + * @param target + * the component to read from, not {@code null} + * @param propertyName + * the JS property name, not {@code null} + * @param valueType + * runtime type of the produced value, not {@code null} + */ + public PropertyArgument(Component target, String propertyName, + Class valueType) { + super(TYPE_ID, valueType); + this.target = Objects.requireNonNull(target).getElement(); + this.propertyName = Objects.requireNonNull(propertyName); + } + + /** + * @return the target element + */ + public Element getTarget() { + return target; + } + + /** + * @return the property name being read + */ + public String getPropertyName() { + return propertyName; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("property", propertyName); + node.put("element", context.referenceElement(target)); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/SetEnabledAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/SetEnabledAction.java new file mode 100644 index 00000000000..e703ce129f7 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/SetEnabledAction.java @@ -0,0 +1,88 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Sets a target component's enabled state. Runs client-side by toggling the + * {@code disabled} attribute so the change is visible the instant the + * triggering DOM event handler returns — closing the latency window in which a + * user could otherwise click the component a second time before the server + * acknowledges the first click. + *

+ * The server-side {@code Component.setEnabled(boolean)} mirror is applied in + * the next server cycle so application code observes the same enabled state. + */ +public class SetEnabledAction extends AbstractAction { + + public static final String TYPE_ID = "flow:set-enabled"; + + private final Element target; + private final boolean enabled; + + /** + * Creates a set-enabled action. + * + * @param target + * the component to enable or disable, not {@code null} + * @param enabled + * {@code true} to enable, {@code false} to disable + */ + public SetEnabledAction(Component target, boolean enabled) { + super(TYPE_ID); + this.target = Objects.requireNonNull(target).getElement(); + this.enabled = enabled; + } + + /** + * @return the target element + */ + public Element getTarget() { + return target; + } + + /** + * @return the value the action sets + */ + public boolean isEnabled() { + return enabled; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + node.put("element", context.referenceElement(target)); + node.put("enabled", enabled); + // Signal to the client that it needs to notify the server after + // applying the local change, so the framework keeps server state + // in sync. + node.put("mirror", true); + return node; + } + + @Override + public void applyServerSideEffect() { + target.setEnabled(enabled); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ShortcutTrigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ShortcutTrigger.java new file mode 100644 index 00000000000..041d27c85ca --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ShortcutTrigger.java @@ -0,0 +1,100 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.KeyModifier; +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Fires when the given key (with optional modifiers) is pressed while the host + * component is the active scope. The shortcut listens for {@code keydown} + * events that originate inside the host's root element, matching + * {@code event.key} against the configured {@link Key} and + * {@code event.ctrlKey / altKey / shiftKey / metaKey} against the configured + * {@link KeyModifier} set. + *

+ * Bound actions run inside the keydown handler, preserving the user-gesture + * context. + * + *

{@code
+ * new ShortcutTrigger(form, Key.ENTER, KeyModifier.CONTROL)
+ *         .triggers(new ClickAction(submitButton));
+ * }
+ */ +public class ShortcutTrigger extends AbstractTrigger { + + public static final String TYPE_ID = "flow:shortcut"; + + private final Key key; + private final Set modifiers; + + /** + * Creates a shortcut trigger bound to the given host component. + * + * @param host + * the component acting as the shortcut's scope, not {@code null} + * @param key + * the key to listen for, not {@code null} + * @param modifiers + * modifier keys that must be held; empty for none + */ + public ShortcutTrigger(Component host, Key key, KeyModifier... modifiers) { + super(TYPE_ID, host); + this.key = Objects.requireNonNull(key); + this.modifiers = modifiers.length == 0 + ? EnumSet.noneOf(KeyModifier.class) + : EnumSet.copyOf(java.util.Arrays.asList(modifiers)); + } + + /** + * @return the key this shortcut listens for + */ + public Key getKey() { + return key; + } + + /** + * @return an unmodifiable view of the modifier set + */ + public Set getModifiers() { + return java.util.Collections.unmodifiableSet(modifiers); + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + ObjectNode node = JacksonUtils.createObjectNode(); + // Key.getKeys() returns the list of event.key values that map to the + // logical key. Send the first one; the others are browser/OS variants + // and the canonical one is enough for matching in v0. + node.put("key", key.getKeys().get(0)); + ArrayNode mods = JacksonUtils.createArrayNode(); + for (KeyModifier modifier : modifiers) { + mods.add(modifier.getKeys().get(0)); + } + node.set("modifiers", mods); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/SignalArgument.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/SignalArgument.java new file mode 100644 index 00000000000..6add4e5f5bd --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/SignalArgument.java @@ -0,0 +1,122 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ConfigContext; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.ElementEffect; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.shared.Registration; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Argument backed by a server-side {@link Signal}. The signal's current value + * is snapshotted into the client config at each emit; a host-scoped effect + * subscribes to the signal so that every change re-emits the snapshot. + *

+ * Snapshot semantics: actions read the latest value at trigger fire time. This + * is a value reader, not a graph builder — composing computed arguments remains + * the signal layer's job (use {@link Signal#cached}). + * + *

{@code
+ * ValueSignal locale = ...;
+ * new ClickTrigger(button).triggers(
+ *         new ClipboardCopyAction(new SignalArgument<>(String.class, locale)));
+ * }
+ * + * The effect that wires the re-emit is created lazily on the first + * {@code buildClientConfig} call against an attached host, and is cleaned up + * automatically by {@link ElementEffect} when the host detaches. + * + * @param + * the runtime type of the produced value + */ +public class SignalArgument extends AbstractArgument { + + public static final String TYPE_ID = "flow:signal-value"; + + private final Signal signal; + + private transient boolean effectInitialRun; + private transient @Nullable Registration effectRegistration; + + /** + * Creates a signal-backed argument. + * + * @param valueType + * runtime type of the produced value, not {@code null} + * @param signal + * the source signal, not {@code null} + */ + public SignalArgument(Class valueType, Signal signal) { + super(TYPE_ID, valueType); + this.signal = Objects.requireNonNull(signal); + } + + /** + * Convenience for the common case: pair a {@link ValueSignal} with an + * argument of the same value type. + * + * @param signal + * the source signal, not {@code null} + * @param valueType + * runtime type, not {@code null} + * @param + * the value type + * @return a new SignalArgument + */ + public static SignalArgument of(ValueSignal signal, + Class valueType) { + return new SignalArgument<>(valueType, signal); + } + + /** + * @return the source signal + */ + public Signal getSignal() { + return signal; + } + + @Override + public ObjectNode buildClientConfig(ConfigContext context) { + Element host = context.getHost(); + if (effectRegistration == null && host.getNode().isAttached()) { + // Install once per attach. ElementEffect re-runs the action + // whenever any signal read inside it changes; the first run + // happens immediately to discover dependencies, so we skip + // it explicitly here. + effectInitialRun = true; + effectRegistration = ElementEffect.effect(host, () -> { + // Read the signal to register the dependency. + signal.get(); + if (effectInitialRun) { + effectInitialRun = false; + } else { + context.scheduleSync(); + } + }); + } + ObjectNode node = JacksonUtils.createObjectNode(); + node.set("value", JacksonUtils.getMapper().valueToTree(signal.peek())); + return node; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/Trigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Trigger.java new file mode 100644 index 00000000000..cf19ce7e7dd --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/Trigger.java @@ -0,0 +1,75 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.io.Serializable; + +import com.vaadin.flow.function.SerializableRunnable; + +/** + * Something that fires on the client and, when it does, runs one or more + * {@link Action actions} synchronously inside the original DOM event handler. + *

+ * A trigger is bound to a host {@link com.vaadin.flow.dom.Element element}. It + * is created with one of the built-in subclasses or with an add-on subclass of + * {@link AbstractTrigger}, and then wired to actions via {@link #triggers}: + * + *

{@code
+ * Trigger click = new ClickTrigger(button);
+ * click.triggers(new ClipboardCopyAction(
+ *         new PropertyArgument<>(textField, "value", String.class)));
+ * }
+ * + * Calling {@code triggers} more than once is additive — every subsequent call + * adds another binding to the same trigger. + *

+ * Triggers and actions run client-side without a server round-trip. Actions may + * still cause a server round-trip if they have a server-side effect (e.g. + * updating a server-side property mirror) or if a server callback is attached + * via {@link #triggers(SerializableRunnable)}. + */ +public interface Trigger extends Serializable { + + /** + * Wires the given actions to this trigger. They run in the order given, + * inside the original DOM event handler, the next time this trigger fires. + * + * @param actions + * the actions to run, not {@code null} + * @return this trigger, for chaining + */ + Trigger triggers(Action... actions); + + /** + * Wires a server-side callback to this trigger. The callback runs after the + * client-side dispatch of any other bound actions has finished. The + * callback fires on the UI thread. + *

+ * This is sugar for adding a {@code ServerCallbackAction} that wraps the + * given runnable. + * + * @param serverHandler + * the runnable to execute on the server, not {@code null} + * @return this trigger, for chaining + */ + Trigger triggers(SerializableRunnable serverHandler); + + /** + * Removes this trigger and all bindings created from it. The corresponding + * client-side handlers are detached as part of the next synchronisation. + */ + void remove(); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ConfigContext.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ConfigContext.java new file mode 100644 index 00000000000..ac8e7ef8de7 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ConfigContext.java @@ -0,0 +1,82 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger.internal; + +import java.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.Argument; +import com.vaadin.flow.dom.Element; + +/** + * Context passed into {@code buildClientConfig} so trigger/action/argument + * subclasses can reference other arguments and elements by stable id without + * needing direct access to the host's {@link TriggerSupport}. + *

+ * For internal use only. + */ +public interface ConfigContext extends Serializable { + + /** + * Returns a stable id for the given argument, registering it with the + * host's TriggerSupport if it hasn't been registered yet. + * + * @param argument + * the argument to reference, not {@code null} + * @return the id of the argument in the surrounding snapshot + */ + int registerArgument(Argument argument); + + /** + * Returns a stable parameter index for the given element. Host element is + * index {@code 0} ({@code this} in the executeJs invocation); other + * elements get sequential indices starting at {@code 1}. + * + * @param element + * the element to reference, not {@code null} + * @return the parameter index + */ + int referenceElement(Element element); + + /** + * Returns a stable parameter index for the given component's root element. + * + * @param component + * the component to reference, not {@code null} + * @return the parameter index + */ + default int referenceElement(Component component) { + return referenceElement(Objects.requireNonNull(component).getElement()); + } + + /** + * The host element this snapshot belongs to. Useful for arguments that + * install element-scoped subscriptions (e.g. {@code SignalArgument} via + * {@link com.vaadin.flow.dom.ElementEffect#effect}). + * + * @return the host element + */ + Element getHost(); + + /** + * Schedules a fresh client snapshot for the host to be emitted on the next + * {@code beforeClientResponse} flush. Used by arguments whose value may + * change between trigger fires (e.g. a {@code SignalArgument}). Idempotent + * within a request. + */ + void scheduleSync(); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ServerCallbackAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ServerCallbackAction.java new file mode 100644 index 00000000000..49188701f42 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ServerCallbackAction.java @@ -0,0 +1,62 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger.internal; + +import java.util.Objects; + +import com.vaadin.flow.component.trigger.AbstractAction; +import com.vaadin.flow.function.SerializableRunnable; + +/** + * Action that runs a server-side {@link SerializableRunnable} when its trigger + * fires. Used by + * {@link com.vaadin.flow.component.trigger.Trigger#triggers(SerializableRunnable)}. + *

+ * On the client, the {@code flow:server-callback} factory's {@code run()} + * simply notifies the server via the per-host return channel; the server's + * {@code TriggerSupport.dispatchMirror} then invokes + * {@link #applyServerSideEffect()} which calls the wrapped runnable on the UI + * thread. + *

+ * For internal use only. + */ +public final class ServerCallbackAction extends AbstractAction { + + public static final String TYPE_ID = "flow:server-callback"; + + private final SerializableRunnable handler; + + /** + * @param handler + * the server-side handler, not {@code null} + */ + public ServerCallbackAction(SerializableRunnable handler) { + super(TYPE_ID); + this.handler = Objects.requireNonNull(handler); + } + + /** + * @return the wrapped handler + */ + public SerializableRunnable getHandler() { + return handler; + } + + @Override + public void applyServerSideEffect() { + handler.run(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/TriggerSupport.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/TriggerSupport.java new file mode 100644 index 00000000000..3bbedd39a2d --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/TriggerSupport.java @@ -0,0 +1,419 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger.internal; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.trigger.AbstractAction; +import com.vaadin.flow.component.trigger.AbstractArgument; +import com.vaadin.flow.component.trigger.AbstractTrigger; +import com.vaadin.flow.component.trigger.Action; +import com.vaadin.flow.component.trigger.Argument; +import com.vaadin.flow.dom.DisabledUpdateMode; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.StateNode; +import com.vaadin.flow.internal.nodefeature.ReturnChannelMap; +import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration; +import com.vaadin.flow.internal.nodefeature.ServerSideFeature; + +/** + * Per-element store of triggers, actions, arguments and bindings for the + * trigger API. Lazily instantiated by {@link #on(Element)}. Emits client + * snapshots via {@link Element#executeJs(String, Object...)} on every binding + * change and on each (re-)attach. + *

+ * For internal use only. + */ +public class TriggerSupport extends ServerSideFeature implements ConfigContext { + + private final Map triggerIds = new IdentityHashMap<>(); + private final Map actionIds = new IdentityHashMap<>(); + private final Map, Integer> argumentIds = new IdentityHashMap<>(); + + private final Map triggersById = new LinkedHashMap<>(); + private final Map actionsById = new LinkedHashMap<>(); + private final Map> argumentsById = new LinkedHashMap<>(); + + private record Binding(int triggerId, + int[] actionIds) implements Serializable { + } + + private final List bindings = new ArrayList<>(); + + private final List elementParams = new ArrayList<>(); + private final Map elementParamIndex = new IdentityHashMap<>(); + + private int nextTriggerId = 0; + private int nextActionId = 0; + private int nextArgumentId = 0; + + private boolean attachListenerRegistered = false; + private boolean syncScheduled = false; + + private transient @Nullable ReturnChannelRegistration mirrorChannel; + + /** + * Creates a TriggerSupport feature for the given state node. + * + * @param node + * the node + */ + public TriggerSupport(StateNode node) { + super(node); + } + + /** + * Gets or creates the TriggerSupport for the given element. + * + * @param host + * the element, not {@code null} + * @return the TriggerSupport instance, never {@code null} + */ + public static TriggerSupport on(Element host) { + Objects.requireNonNull(host); + return host.getNode().getFeature(TriggerSupport.class); + } + + /** + * Gets or creates the TriggerSupport for the given component's root + * element. + * + * @param host + * the component, not {@code null} + * @return the TriggerSupport instance, never {@code null} + */ + public static TriggerSupport on(Component host) { + Objects.requireNonNull(host); + return on(host.getElement()); + } + + /** + * Registers a trigger with this support, assigning it an id. + * + * @param trigger + * the trigger, not {@code null} + * @return the assigned id + */ + public int registerTrigger(AbstractTrigger trigger) { + Objects.requireNonNull(trigger); + return triggerIds.computeIfAbsent(trigger, t -> { + int id = nextTriggerId++; + triggersById.put(id, t); + return id; + }); + } + + /** + * Registers an action with this support, assigning it an id, deduping by + * identity. + * + * @param action + * the action, not {@code null} + * @return the assigned id + */ + public int registerAction(AbstractAction action) { + Objects.requireNonNull(action); + return actionIds.computeIfAbsent(action, a -> { + int id = nextActionId++; + actionsById.put(id, a); + return id; + }); + } + + /** + * Registers an argument with this support, assigning it an id, deduping by + * identity. + * + * @param argument + * the argument, not {@code null} + * @return the assigned id + */ + @Override + public int registerArgument(Argument argument) { + Objects.requireNonNull(argument); + if (!(argument instanceof AbstractArgument abstractArgument)) { + throw new IllegalArgumentException( + "Argument must extend AbstractArgument: " + argument); + } + return argumentIds.computeIfAbsent(abstractArgument, o -> { + int id = nextArgumentId++; + argumentsById.put(id, o); + return id; + }); + } + + /** + * Returns a parameter index for the given element to be passed alongside + * the snapshot. The host's own element is at index 0 (the {@code this} of + * the executeJs invocation); other elements get sequential indices starting + * at 1. + * + * @param element + * the element to reference, not {@code null} + * @return the parameter index + */ + @Override + public int referenceElement(Element element) { + Objects.requireNonNull(element); + if (element == getHost()) { + return 0; + } + Integer existing = elementParamIndex.get(element); + if (existing != null) { + return existing; + } + int index = elementParams.size() + 1; + elementParams.add(element); + elementParamIndex.put(element, index); + return index; + } + + /** + * Adds a binding from a trigger to a sequence of actions. + * + * @param trigger + * the trigger, not {@code null} + * @param actions + * the actions, not {@code null} or empty + */ + public void bind(AbstractTrigger trigger, Action[] actions) { + Objects.requireNonNull(trigger); + Objects.requireNonNull(actions); + if (actions.length == 0) { + throw new IllegalArgumentException( + "At least one action is required"); + } + int triggerId = registerTrigger(trigger); + int[] actionIdArr = new int[actions.length]; + for (int i = 0; i < actions.length; i++) { + Action action = actions[i]; + Objects.requireNonNull(action, "Action must not be null"); + if (!(action instanceof AbstractAction abstractAction)) { + throw new IllegalArgumentException( + "Action must extend AbstractAction: " + action); + } + actionIdArr[i] = registerAction(abstractAction); + } + bindings.add(new Binding(triggerId, actionIdArr)); + scheduleSync(); + } + + /** + * Removes a trigger and all bindings created from it. + * + * @param trigger + * the trigger, not {@code null} + */ + public void removeTrigger(AbstractTrigger trigger) { + Objects.requireNonNull(trigger); + Integer id = triggerIds.remove(trigger); + if (id == null) { + return; + } + triggersById.remove(id); + bindings.removeIf(b -> b.triggerId() == id); + scheduleSync(); + } + + /** + * Looks up an action by id. Used when the client posts a server-side mirror + * back over the {@code applyServerSideEffect} channel. + * + * @param id + * the action id + * @return the action, or {@code null} if unknown + */ + public @Nullable AbstractAction getAction(int id) { + return actionsById.get(id); + } + + @Override + public Element getHost() { + return Element.get(getNode()); + } + + @Override + public void scheduleSync() { + if (!attachListenerRegistered) { + attachListenerRegistered = true; + getHost().addAttachListener(e -> syncToClient()); + } + if (syncScheduled) { + return; + } + syncScheduled = true; + getNode().runWhenAttached(ui -> ui.getInternals().getStateTree() + .beforeClientResponse(getNode(), ctx -> flushSync())); + } + + private void flushSync() { + syncScheduled = false; + if (!getNode().isAttached()) { + return; + } + syncToClient(); + } + + private void syncToClient() { + Element host = getHost(); + ObjectNode snapshot = buildSnapshot(); + ReturnChannelRegistration channel = getMirrorChannel(); + // Parameter layout: $0 = snapshot, $1..$N = extra elements, + // $N+1 = mirror channel function. Pre-computed before assembling + // the executeJs expression so the indices line up. + int extras = elementParams.size(); + int channelIndex = 1 + extras; + Object[] params = new Object[2 + extras]; + params[0] = snapshot; + for (int i = 0; i < extras; i++) { + params[i + 1] = elementParams.get(i); + } + params[channelIndex] = channel; + + StringBuilder call = new StringBuilder( + "if (window.Vaadin && window.Vaadin.Flow && window.Vaadin.Flow.triggers) {" + + " window.Vaadin.Flow.triggers.bind(this, $0"); + call.append(", ["); + for (int i = 0; i < extras; i++) { + if (i > 0) { + call.append(','); + } + call.append('$').append(i + 1); + } + call.append("], $").append(channelIndex); + call.append("); } else { console.debug(") + .append("'window.Vaadin.Flow.triggers not loaded'); }"); + host.executeJs(call.toString(), params); + } + + private ReturnChannelRegistration getMirrorChannel() { + if (mirrorChannel == null) { + mirrorChannel = getNode().getFeature(ReturnChannelMap.class) + .registerChannel(this::dispatchMirror) + .setDisabledUpdateMode(DisabledUpdateMode.ALWAYS); + } + return mirrorChannel; + } + + private void dispatchMirror(ArrayNode args) { + if (args.isEmpty()) { + return; + } + int actionId = args.get(0).asInt(); + AbstractAction action = actionsById.get(actionId); + if (action == null) { + return; + } + action.applyServerSideEffect(); + } + + private ObjectNode buildSnapshot() { + ObjectNode root = JacksonUtils.createObjectNode(); + // Iterate over copies so that builders that register new arguments + // (which mutates argumentsById) don't disturb the in-progress + // iteration. Order matters: triggers and actions may register + // arguments, and arguments may reference elements — process triggers + // and actions first, then arguments last. + ObjectNode triggersNode = JacksonUtils.createObjectNode(); + for (Map.Entry e : List + .copyOf(triggersById.entrySet())) { + ObjectNode entry = JacksonUtils.createObjectNode(); + entry.put("type", e.getValue().getTypeId()); + entry.set("config", e.getValue().buildClientConfig(this)); + triggersNode.set(e.getKey().toString(), entry); + } + root.set("triggers", triggersNode); + + ObjectNode actionsNode = JacksonUtils.createObjectNode(); + for (Map.Entry e : List + .copyOf(actionsById.entrySet())) { + ObjectNode entry = JacksonUtils.createObjectNode(); + entry.put("type", e.getValue().getTypeId()); + entry.set("config", e.getValue().buildClientConfig(this)); + actionsNode.set(e.getKey().toString(), entry); + } + root.set("actions", actionsNode); + + ObjectNode argumentsNode = JacksonUtils.createObjectNode(); + for (Map.Entry> e : List + .copyOf(argumentsById.entrySet())) { + ObjectNode entry = JacksonUtils.createObjectNode(); + entry.put("type", e.getValue().getTypeId()); + entry.set("config", e.getValue().buildClientConfig(this)); + argumentsNode.set(e.getKey().toString(), entry); + } + root.set("arguments", argumentsNode); + + ArrayNode bindingsNode = JacksonUtils.createArrayNode(); + for (Binding b : bindings) { + ObjectNode entry = JacksonUtils.createObjectNode(); + entry.put("trigger", b.triggerId()); + ArrayNode actionsArr = JacksonUtils.createArrayNode(); + for (int a : b.actionIds()) { + actionsArr.add(a); + } + entry.set("actions", actionsArr); + bindingsNode.add(entry); + } + root.set("bindings", bindingsNode); + return root; + } + + // Test-only accessors. + + /** + * Builds the snapshot for testing. + * + * @return the snapshot + */ + public ObjectNode snapshotForTest() { + return buildSnapshot(); + } + + /** + * Parameter array (excluding the host at index 0) for testing. + * + * @return the secondary elements + */ + public Element[] elementParamsForTest() { + return elementParams.toArray(new Element[0]); + } + + /** + * Invokes the mirror dispatch for the given action id, as if the client had + * reported the action as fired. For testing only. + * + * @param actionId + * the action id + */ + public void dispatchMirrorForTest(int actionId) { + ArrayNode args = JacksonUtils.createArrayNode(); + args.add(actionId); + dispatchMirror(args); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/package-info.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/package-info.java new file mode 100644 index 00000000000..4c39424445b --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +/** + * Internal wiring for the trigger API. Not part of the public API; classes here + * may be renamed or removed in any release. + */ +@NullMarked +package com.vaadin.flow.component.trigger.internal; + +import org.jspecify.annotations.NullMarked; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java new file mode 100644 index 00000000000..669f79e7b12 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java @@ -0,0 +1,32 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +/** + * Server-side API for wiring client-side triggers (DOM events, shortcuts, …) to + * client-side actions (clipboard copy, set property, run JS, …) reading values + * from arguments, without a server round-trip when not needed. + *

+ * The trigger API is intentionally open for extension: applications and add-ons + * declare new trigger, action and argument types by extending + * {@link com.vaadin.flow.component.trigger.AbstractTrigger}, + * {@link com.vaadin.flow.component.trigger.AbstractAction} or + * {@link com.vaadin.flow.component.trigger.AbstractArgument} and pairing them + * with a JavaScript handler registered under the same type id against + * {@code window.Vaadin.Flow.triggers}. + */ +@NullMarked +package com.vaadin.flow.component.trigger; + +import org.jspecify.annotations.NullMarked; diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java index bd16e79968d..945f75df0e2 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java @@ -26,6 +26,7 @@ import tools.jackson.databind.JsonNode; +import com.vaadin.flow.component.trigger.internal.TriggerSupport; import com.vaadin.flow.dom.ClassList; import com.vaadin.flow.dom.DomEventListener; import com.vaadin.flow.dom.DomListenerRegistration; @@ -90,8 +91,8 @@ public class BasicElementStateProvider extends AbstractNodeStateProvider { PolymerServerEventHandlers.class, ClientCallableHandlers.class, PolymerEventListenerMap.class, ShadowRootData.class, AttachExistingElementFeature.class, VirtualChildrenList.class, - ReturnChannelMap.class, InertData.class, - SignalBindingFeature.class }; + ReturnChannelMap.class, InertData.class, SignalBindingFeature.class, + TriggerSupport.class }; private BasicElementStateProvider() { // Not meant to be sub classed and only once instance should ever exist diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java index a0cf2e794fe..2adcb988040 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.Map; +import com.vaadin.flow.component.trigger.internal.TriggerSupport; import com.vaadin.flow.function.SerializableFunction; import com.vaadin.flow.internal.StateNode; import com.vaadin.flow.internal.nodefeature.PushConfigurationMap.PushConfigurationParametersMap; @@ -75,6 +76,10 @@ private NodeFeatureData( registerFeature(SignalBindingFeature.class, SignalBindingFeature::new, NodeFeatures.SIGNAL_BINDING); + /* Trigger API */ + registerFeature(TriggerSupport.class, TriggerSupport::new, + NodeFeatures.TRIGGER_SUPPORT); + /* Common element features */ registerFeature(ElementChildrenList.class, ElementChildrenList::new, NodeFeatures.ELEMENT_CHILDREN); diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java index 1378bb57e09..5672b7e754c 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java @@ -147,6 +147,11 @@ public final class NodeFeatures { */ public static final int SIGNAL_BINDING = 27; + /** + * Id for {@code TriggerSupport}. + */ + public static final int TRIGGER_SUPPORT = 28; + private NodeFeatures() { // Only static } diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/JsEscapeHatchTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/JsEscapeHatchTest.java new file mode 100644 index 00000000000..fa32e07f295 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/JsEscapeHatchTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.TriggerSupport; + +public class JsEscapeHatchTest { + + @Test + public void jsTriggerActionAndArgument_encodeExpressionsAndArgumentIds() { + TagComponent host = new TagComponent("div"); + + JsArgument answer = new JsArgument<>(String.class, + "return 'forty-two';"); + new JsTrigger(host, "this.addEventListener('dblclick', trigger);") + .triggers(new JsAction( + "this.querySelector('#out').textContent = argument(0);", + answer)); + + ObjectNode snapshot = TriggerSupport.on(host).snapshotForTest(); + + JsonNode trigger = snapshot.get("triggers").get("0"); + Assert.assertEquals(JsTrigger.TYPE_ID, trigger.get("type").asString()); + Assert.assertEquals("this.addEventListener('dblclick', trigger);", + trigger.get("config").get("expression").asString()); + + JsonNode action = snapshot.get("actions").get("0"); + Assert.assertEquals(JsAction.TYPE_ID, action.get("type").asString()); + Assert.assertEquals( + "this.querySelector('#out').textContent = argument(0);", + action.get("config").get("expression").asString()); + JsonNode arguments = action.get("config").get("arguments"); + Assert.assertEquals(1, arguments.size()); + Assert.assertEquals(0, arguments.get(0).asInt()); + + JsonNode argument = snapshot.get("arguments").get("0"); + Assert.assertEquals(JsArgument.TYPE_ID, + argument.get("type").asString()); + Assert.assertEquals("return 'forty-two';", + argument.get("config").get("expression").asString()); + } + + @Test + public void jsAction_dedupsSharedArgumentAcrossMixedTypes() { + TagComponent host = new TagComponent("div"); + TagComponent field = new TagComponent("input"); + + // One argument reused across two actions of different types: a built-in + // ClipboardCopyAction and a JsAction. The argument pool should still + // contain exactly one entry, and both actions should reference id 0. + Argument value = new PropertyArgument<>(field, "value", + String.class); + new JsTrigger(host, "this.addEventListener('input', trigger);") + .triggers(new ClipboardCopyAction(value), + new JsAction("alert(argument(0));", value)); + + ObjectNode snapshot = TriggerSupport.on(host).snapshotForTest(); + Assert.assertEquals(1, snapshot.get("arguments").size()); + + JsonNode clipboard = snapshot.get("actions").get("0"); + JsonNode js = snapshot.get("actions").get("1"); + Assert.assertEquals(0, clipboard.get("config").get("text").asInt()); + Assert.assertEquals(1, js.get("config").get("arguments").size()); + Assert.assertEquals(0, + js.get("config").get("arguments").get(0).asInt()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/ServerCallbackTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ServerCallbackTest.java new file mode 100644 index 00000000000..326fd0366ee --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ServerCallbackTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.ServerCallbackAction; +import com.vaadin.flow.component.trigger.internal.TriggerSupport; + +public class ServerCallbackTest { + + @Test + public void triggers_runnable_addsServerCallbackToSnapshot() { + TagComponent button = new TagComponent("button"); + AtomicInteger calls = new AtomicInteger(); + + new ClickTrigger(button).triggers(() -> calls.incrementAndGet()); + + ObjectNode snapshot = TriggerSupport.on(button).snapshotForTest(); + JsonNode action = snapshot.get("actions").get("0"); + Assert.assertEquals(ServerCallbackAction.TYPE_ID, + action.get("type").asString()); + Assert.assertEquals(0, calls.get()); + } + + @Test + public void dispatchMirror_runsWrappedRunnable() { + TagComponent button = new TagComponent("button"); + AtomicInteger calls = new AtomicInteger(); + + new ClickTrigger(button).triggers(() -> calls.incrementAndGet()); + + TriggerSupport.on(button).dispatchMirrorForTest(0); + + Assert.assertEquals(1, calls.get()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/ShortcutAndMirrorTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ShortcutAndMirrorTest.java new file mode 100644 index 00000000000..c7dd6952562 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ShortcutAndMirrorTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.KeyModifier; +import com.vaadin.flow.component.trigger.internal.TriggerSupport; + +public class ShortcutAndMirrorTest { + + @Test + public void shortcutTrigger_encodesKeyAndModifiers() { + TagComponent form = new TagComponent("form"); + TagComponent submit = new TagComponent("button"); + + new ShortcutTrigger(form, Key.ENTER, KeyModifier.CONTROL, + KeyModifier.SHIFT).triggers(new ClickAction(submit)); + + ObjectNode snapshot = TriggerSupport.on(form).snapshotForTest(); + JsonNode trigger = snapshot.get("triggers").get("0"); + Assert.assertEquals(ShortcutTrigger.TYPE_ID, + trigger.get("type").asString()); + JsonNode cfg = trigger.get("config"); + Assert.assertEquals("Enter", cfg.get("key").asString()); + JsonNode mods = cfg.get("modifiers"); + Assert.assertEquals(2, mods.size()); + java.util.Set values = new java.util.HashSet<>(); + mods.forEach(n -> values.add(n.asString())); + Assert.assertEquals(java.util.Set.of("Control", "Shift"), values); + } + + @Test + public void setEnabledAction_encodesElementAndMirrorFlag() { + TagComponent form = new TagComponent("form"); + TagComponent submit = new TagComponent("button"); + + new ShortcutTrigger(form, Key.ENTER) + .triggers(new SetEnabledAction(submit, false)); + + ObjectNode snapshot = TriggerSupport.on(form).snapshotForTest(); + JsonNode action = snapshot.get("actions").get("0"); + Assert.assertEquals(SetEnabledAction.TYPE_ID, + action.get("type").asString()); + JsonNode cfg = action.get("config"); + Assert.assertEquals(1, cfg.get("element").asInt()); + Assert.assertFalse(cfg.get("enabled").asBoolean()); + Assert.assertTrue(cfg.get("mirror").asBoolean()); + } + + @Test + public void dispatchMirror_appliesSetEnabledServerSide() { + TagComponent form = new TagComponent("form"); + TagComponent submit = new TagComponent("button"); + Assert.assertTrue("button starts enabled", + submit.getElement().isEnabled()); + + SetEnabledAction disable = new SetEnabledAction(submit, false); + new ShortcutTrigger(form, Key.ENTER).triggers(disable); + + // Simulate the client reporting that action 0 fired locally. + TriggerSupport.on(form).dispatchMirrorForTest(0); + + Assert.assertFalse("server-side button is now disabled", + submit.getElement().isEnabled()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/SignalArgumentTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/SignalArgumentTest.java new file mode 100644 index 00000000000..e23160b98c1 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/SignalArgumentTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.TriggerSupport; +import com.vaadin.flow.signals.local.ValueSignal; + +public class SignalArgumentTest { + + @Test + public void buildClientConfig_shipsCurrentSignalValue() { + TagComponent button = new TagComponent("button"); + ValueSignal signal = new ValueSignal<>("alpha"); + + new ClickTrigger(button).triggers(new ClipboardCopyAction( + new SignalArgument<>(String.class, signal))); + + ObjectNode snapshot = TriggerSupport.on(button).snapshotForTest(); + JsonNode argument = snapshot.get("arguments").get("0"); + Assert.assertEquals(SignalArgument.TYPE_ID, + argument.get("type").asString()); + Assert.assertEquals("alpha", + argument.get("config").get("value").asString()); + } + + @Test + public void buildClientConfig_reflectsSignalUpdates() { + TagComponent button = new TagComponent("button"); + ValueSignal signal = new ValueSignal<>("alpha"); + new ClickTrigger(button).triggers(new ClipboardCopyAction( + new SignalArgument<>(String.class, signal))); + + // First snapshot + ObjectNode first = TriggerSupport.on(button).snapshotForTest(); + Assert.assertEquals("alpha", first.get("arguments").get("0") + .get("config").get("value").asString()); + + // Mutate the signal, then rebuild + signal.set("beta"); + ObjectNode second = TriggerSupport.on(button).snapshotForTest(); + Assert.assertEquals("beta", second.get("arguments").get("0") + .get("config").get("value").asString()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/TagComponent.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/TagComponent.java new file mode 100644 index 00000000000..42cc987d518 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/TagComponent.java @@ -0,0 +1,33 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.dom.Element; + +/** + * Minimal {@link Component} wrapper around an arbitrary tag, used by the + * trigger unit tests in place of dragging flow-html-components into the server + * test classpath. + */ +@Tag("test-component") +final class TagComponent extends Component { + + TagComponent(String tag) { + super(new Element(tag)); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/TriggerSupportTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/TriggerSupportTest.java new file mode 100644 index 00000000000..a653360c97e --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/TriggerSupportTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.component.trigger; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.trigger.internal.TriggerSupport; +import com.vaadin.flow.dom.Element; + +public class TriggerSupportTest { + + @Test + public void snapshot_includesTriggerActionArgumentAndBinding() { + TagComponent button = new TagComponent("button"); + TagComponent field = new TagComponent("input"); + + Argument value = new PropertyArgument<>(field, "value", + String.class); + ClickTrigger trigger = new ClickTrigger(button); + trigger.triggers(new ClipboardCopyAction(value)); + + TriggerSupport support = TriggerSupport.on(button); + ObjectNode snapshot = support.snapshotForTest(); + + // Triggers pool has one click trigger keyed by its assigned id 0 + JsonNode triggers = snapshot.get("triggers"); + Assert.assertEquals(1, triggers.size()); + JsonNode triggerEntry = triggers.get("0"); + Assert.assertNotNull("trigger 0 in pool", triggerEntry); + Assert.assertEquals(ClickTrigger.TYPE_ID, + triggerEntry.get("type").asString()); + + // Actions pool has one clipboard-copy referencing argument id 0 + JsonNode actions = snapshot.get("actions"); + Assert.assertEquals(1, actions.size()); + JsonNode actionEntry = actions.get("0"); + Assert.assertNotNull("action 0 in pool", actionEntry); + Assert.assertEquals(ClipboardCopyAction.TYPE_ID, + actionEntry.get("type").asString()); + Assert.assertEquals(0, actionEntry.get("config").get("text").asInt()); + + // Arguments pool has one property argument with element index 1 (host + // is index 0, field is the first extra element) + JsonNode arguments = snapshot.get("arguments"); + Assert.assertEquals(1, arguments.size()); + JsonNode argumentEntry = arguments.get("0"); + Assert.assertNotNull("argument 0 in pool", argumentEntry); + Assert.assertEquals(PropertyArgument.TYPE_ID, + argumentEntry.get("type").asString()); + Assert.assertEquals("value", + argumentEntry.get("config").get("property").asString()); + Assert.assertEquals(1, + argumentEntry.get("config").get("element").asInt()); + + // Bindings list has one binding from trigger 0 to action 0 + JsonNode bindings = snapshot.get("bindings"); + Assert.assertEquals(1, bindings.size()); + Assert.assertEquals(0, bindings.get(0).get("trigger").asInt()); + Assert.assertEquals(1, bindings.get(0).get("actions").size()); + Assert.assertEquals(0, bindings.get(0).get("actions").get(0).asInt()); + + // The field has been collected as a secondary element parameter + Assert.assertArrayEquals(new Element[] { field.getElement() }, + support.elementParamsForTest()); + } + + @Test + public void sharedAction_dedupedById_acrossMultipleBindings() { + TagComponent button = new TagComponent("button"); + TagComponent field = new TagComponent("input"); + ClipboardCopyAction copy = new ClipboardCopyAction( + new PropertyArgument<>(field, "value", String.class)); + + ClickTrigger t1 = new ClickTrigger(button); + ClickTrigger t2 = new ClickTrigger(button); + t1.triggers(copy); + t2.triggers(copy); + + TriggerSupport support = TriggerSupport.on(button); + ObjectNode snapshot = support.snapshotForTest(); + + Assert.assertEquals(2, snapshot.get("triggers").size()); + Assert.assertEquals("shared action gets a single entry", 1, + snapshot.get("actions").size()); + Assert.assertEquals(2, snapshot.get("bindings").size()); + } + + @Test + public void remove_dropsTriggerAndBindings() { + TagComponent button = new TagComponent("button"); + TagComponent field = new TagComponent("input"); + ClipboardCopyAction copy = new ClipboardCopyAction( + new PropertyArgument<>(field, "value", String.class)); + ClickTrigger t1 = new ClickTrigger(button); + ClickTrigger t2 = new ClickTrigger(button); + t1.triggers(copy); + t2.triggers(copy); + + t1.remove(); + + ObjectNode snapshot = TriggerSupport.on(button).snapshotForTest(); + Assert.assertEquals(1, snapshot.get("triggers").size()); + Assert.assertEquals(1, snapshot.get("bindings").size()); + Assert.assertEquals(t2.getTriggerId(), + snapshot.get("bindings").get(0).get("trigger").asInt()); + } + + @Test(expected = IllegalArgumentException.class) + public void bind_emptyActionsRejected() { + TagComponent button = new TagComponent("button"); + new ClickTrigger(button).triggers(new Action[0]); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java index c46bd6b04dd..b97bab59f21 100644 --- a/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java @@ -106,6 +106,9 @@ private static Map, Integer> buildExpectedIdMap() { expectedIds.put(InertData.class, NodeFeatures.INERT_DATA); expectedIds.put(SignalBindingFeature.class, NodeFeatures.SIGNAL_BINDING); + expectedIds.put( + com.vaadin.flow.component.trigger.internal.TriggerSupport.class, + NodeFeatures.TRIGGER_SUPPORT); return expectedIds; } @@ -150,6 +153,9 @@ void priorityOrder() { /* Signal binding feature */ SignalBindingFeature.class, + /* Trigger API */ + com.vaadin.flow.component.trigger.internal.TriggerSupport.class, + /* Common element features */ ElementChildrenList.class, ElementPropertyMap.class, diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyView.java new file mode 100644 index 00000000000..c976ad153da --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyView.java @@ -0,0 +1,49 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import com.vaadin.flow.component.html.Input; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.trigger.Argument; +import com.vaadin.flow.component.trigger.ClickTrigger; +import com.vaadin.flow.component.trigger.ClipboardCopyAction; +import com.vaadin.flow.component.trigger.PropertyArgument; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +/** + * Wires a {@link ClickTrigger} on a button to a {@link ClipboardCopyAction} + * that copies the current value of an {@link Input} to the clipboard. The IT + * replaces {@code navigator.clipboard.writeText} with a recording shim so the + * assertion does not depend on browser clipboard permissions. + */ +@Route(value = "com.vaadin.flow.uitest.ui.TriggerClipboardCopyView", layout = ViewTestLayout.class) +public class TriggerClipboardCopyView extends AbstractDivView { + + @Override + protected void onShow() { + Input field = new Input(); + field.setId("source"); + NativeButton copyButton = new NativeButton("Copy"); + copyButton.setId("copy"); + + add(field, copyButton); + + Argument value = new PropertyArgument<>(field, "value", + String.class); + new ClickTrigger(copyButton).triggers(new ClipboardCopyAction(value)); + } +} diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchView.java new file mode 100644 index 00000000000..72a9ef33989 --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchView.java @@ -0,0 +1,54 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.trigger.Argument; +import com.vaadin.flow.component.trigger.JsAction; +import com.vaadin.flow.component.trigger.JsArgument; +import com.vaadin.flow.component.trigger.JsTrigger; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +/** + * Validates the JS escape hatch: a {@link JsTrigger} wires a click listener on + * a button; a {@link JsAction} reads from a {@link JsArgument} and writes the + * produced value to a {@code }. No custom Java action class and no custom + * client TS module — purely the {@code flow:js} dispatcher. + */ +@Route(value = "com.vaadin.flow.uitest.ui.TriggerJsEscapeHatchView", layout = ViewTestLayout.class) +public class TriggerJsEscapeHatchView extends AbstractDivView { + + @Override + protected void onShow() { + NativeButton button = new NativeButton("Run JS"); + button.setId("run"); + + Span result = new Span("(initial)"); + result.setId("result"); + + add(button, result); + + Argument message = new JsArgument<>(String.class, + "return 'js-escape-hatch';"); + new JsTrigger(button, + "this.addEventListener('click', () => trigger());") + .triggers(new JsAction( + "document.getElementById('result').textContent = argument(0);", + message)); + } +} diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackView.java new file mode 100644 index 00000000000..a051d3c5beb --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackView.java @@ -0,0 +1,50 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.trigger.ClickTrigger; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +/** + * Wires a {@link ClickTrigger} on a button to a server-side {@code Runnable} + * via {@link com.vaadin.flow.component.trigger.Trigger + * #triggers(com.vaadin.flow.function.SerializableRunnable)}. The runnable + * updates a count and a result label so the IT can verify the callback ran on + * the server. + */ +@Route(value = "com.vaadin.flow.uitest.ui.TriggerServerCallbackView", layout = ViewTestLayout.class) +public class TriggerServerCallbackView extends AbstractDivView { + + @Override + protected void onShow() { + NativeButton button = new NativeButton("Fire"); + button.setId("fire"); + + Span result = new Span("(none)"); + result.setId("result"); + + add(button, result); + + AtomicInteger count = new AtomicInteger(); + new ClickTrigger(button).triggers( + () -> result.setText("fired " + count.incrementAndGet())); + } +} diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableView.java new file mode 100644 index 00000000000..353556f195c --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableView.java @@ -0,0 +1,71 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Input; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.trigger.ClickAction; +import com.vaadin.flow.component.trigger.SetEnabledAction; +import com.vaadin.flow.component.trigger.ShortcutTrigger; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +/** + * Wires a {@code ShortcutTrigger(Enter)} on the form to two actions on the + * submit button: + * {@link ClickAction#ClickAction(com.vaadin.flow.component.Component) + * ClickAction(button)} first, then + * {@link SetEnabledAction#SetEnabledAction(com.vaadin.flow.component.Component, boolean) + * SetEnabledAction(button, false)}. The submit button's server-side click + * listener appends to a result label, demonstrating that the click reached the + * server. The local disable then closes the latency window before any second + * user gesture can re-trigger the submit. Ordering matters: a browser blocks + * {@code element.click()} on an already-disabled element, so the click action + * must run while the button is still enabled. + */ +@Route(value = "com.vaadin.flow.uitest.ui.TriggerShortcutDisableView", layout = ViewTestLayout.class) +public class TriggerShortcutDisableView extends AbstractDivView { + + @Override + protected void onShow() { + Div form = new Div(); + form.setId("form"); + // Make sure the div receives keydown events: needs a tabindex so the + // browser treats it as focusable. + form.getElement().setAttribute("tabindex", "0"); + + Input field = new Input(); + field.setId("field"); + + NativeButton submit = new NativeButton("Submit"); + submit.setId("submit"); + + Span result = new Span("(none)"); + result.setId("result"); + + submit.addClickListener( + e -> result.setText("clicked, enabled=" + submit.isEnabled())); + + form.add(field, submit); + add(form, result); + + new ShortcutTrigger(form, Key.ENTER).triggers(new ClickAction(submit), + new SetEnabledAction(submit, false)); + } +} diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentView.java new file mode 100644 index 00000000000..2c074cb0b5d --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentView.java @@ -0,0 +1,52 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.trigger.ClickTrigger; +import com.vaadin.flow.component.trigger.ClipboardCopyAction; +import com.vaadin.flow.component.trigger.SignalArgument; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +/** + * Wires a {@link ClickTrigger} on a button to a {@link ClipboardCopyAction} + * reading from a {@link SignalArgument} backed by a server-side + * {@link ValueSignal}. A second "Update" button mutates the signal so the IT + * can assert the snapshot re-syncs and the clipboard receives the new value on + * the next click. + */ +@Route(value = "com.vaadin.flow.uitest.ui.TriggerSignalArgumentView", layout = ViewTestLayout.class) +public class TriggerSignalArgumentView extends AbstractDivView { + + @Override + protected void onShow() { + ValueSignal message = new ValueSignal<>("first"); + + NativeButton copy = new NativeButton("Copy signal"); + copy.setId("copy"); + + NativeButton update = new NativeButton("Update signal", + e -> message.set("second")); + update.setId("update"); + + add(copy, update); + + new ClickTrigger(copy).triggers(new ClipboardCopyAction( + new SignalArgument<>(String.class, message))); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyIT.java new file mode 100644 index 00000000000..9dd859e5275 --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerClipboardCopyIT.java @@ -0,0 +1,53 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class TriggerClipboardCopyIT extends ChromeBrowserTest { + + @Test + public void clickTriggersClipboardCopyOfFieldValue() { + open(); + + // Stub navigator.clipboard.writeText so the assertion does not + // depend on the browser granting clipboard permissions. + ((JavascriptExecutor) getDriver()) + .executeScript("window.__copied = null;" + + "Object.defineProperty(navigator, 'clipboard', {" + + " configurable: true, value: {" + + " writeText: t => { window.__copied = t; return Promise.resolve(); }" + + " }" + "});"); + + WebElement field = findElement(By.id("source")); + WebElement button = findElement(By.id("copy")); + + ((JavascriptExecutor) getDriver()).executeScript( + "arguments[0].value = 'hello clipboard';", field); + + button.click(); + + Object copied = waitUntil(d -> ((JavascriptExecutor) d) + .executeScript("return window.__copied;")); + Assert.assertEquals("hello clipboard", copied); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchIT.java new file mode 100644 index 00000000000..98357338461 --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerJsEscapeHatchIT.java @@ -0,0 +1,43 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class TriggerJsEscapeHatchIT extends ChromeBrowserTest { + + @Test + public void jsTriggerActionAndArgumentWireUpEndToEnd() { + open(); + + WebElement button = findElement(By.id("run")); + WebElement result = findElement(By.id("result")); + Assert.assertEquals("(initial)", result.getText()); + + button.click(); + + WebElement updated = waitUntil(d -> { + WebElement r = d.findElement(By.id("result")); + return "js-escape-hatch".equals(r.getText()) ? r : null; + }); + Assert.assertEquals("js-escape-hatch", updated.getText()); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackIT.java new file mode 100644 index 00000000000..1bd5d7e03e6 --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerServerCallbackIT.java @@ -0,0 +1,47 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class TriggerServerCallbackIT extends ChromeBrowserTest { + + @Test + public void clickRunsServerCallbackTwice() { + open(); + + WebElement button = findElement(By.id("fire")); + + button.click(); + WebElement first = waitUntil(d -> { + WebElement r = d.findElement(By.id("result")); + return "fired 1".equals(r.getText()) ? r : null; + }); + Assert.assertEquals("fired 1", first.getText()); + + button.click(); + WebElement second = waitUntil(d -> { + WebElement r = d.findElement(By.id("result")); + return "fired 2".equals(r.getText()) ? r : null; + }); + Assert.assertEquals("fired 2", second.getText()); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableIT.java new file mode 100644 index 00000000000..a50771bfb41 --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerShortcutDisableIT.java @@ -0,0 +1,53 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class TriggerShortcutDisableIT extends ChromeBrowserTest { + + @Test + public void enterShortcutClicksAndDisablesSubmitButton() { + open(); + + WebElement field = findElement(By.id("field")); + WebElement submit = findElement(By.id("submit")); + + field.click(); + field.sendKeys("hello", Keys.ENTER); + + // The ClickAction fires first while the button is still enabled, + // so Flow's server-side ClickListener runs and observes + // isEnabled() == true. + WebElement result = waitUntil(d -> { + WebElement r = d.findElement(By.id("result")); + return "clicked, enabled=true".equals(r.getText()) ? r : null; + }); + Assert.assertEquals("clicked, enabled=true", result.getText()); + + // SetEnabledAction then disables the button locally. The browser + // would block any subsequent user-initiated click, closing the + // latency window in which a second submit could otherwise happen. + Assert.assertNotNull("submit button is disabled client-side", + submit.getAttribute("disabled")); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentIT.java new file mode 100644 index 00000000000..5da86e32cdb --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerSignalArgumentIT.java @@ -0,0 +1,64 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Assert; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class TriggerSignalArgumentIT extends ChromeBrowserTest { + + @Test + public void clickCopiesSignalValue_andReflectsServerSideUpdates() { + open(); + + // Stub navigator.clipboard.writeText so the assertion is + // independent of clipboard permissions. + ((JavascriptExecutor) getDriver()) + .executeScript("window.__copied = null;" + + "Object.defineProperty(navigator, 'clipboard', {" + + " configurable: true, value: {" + + " writeText: t => { window.__copied = t; return Promise.resolve(); }" + + " }" + "});"); + + WebElement copy = findElement(By.id("copy")); + WebElement update = findElement(By.id("update")); + + copy.click(); + Object first = waitUntil(d -> { + Object v = ((JavascriptExecutor) d) + .executeScript("return window.__copied;"); + return "first".equals(v) ? v : null; + }); + Assert.assertEquals("first", first); + + // Update the signal server-side. The trigger snapshot must + // re-emit so the next click copies the new value. + update.click(); + + copy.click(); + Object second = waitUntil(d -> { + Object v = ((JavascriptExecutor) d) + .executeScript("return window.__copied;"); + return "second".equals(v) ? v : null; + }); + Assert.assertEquals("second", second); + } +} diff --git a/triggers.md b/triggers.md new file mode 100644 index 00000000000..936e294a336 --- /dev/null +++ b/triggers.md @@ -0,0 +1,364 @@ +# Trigger API — design plan + +Server-side API for wiring **client-side triggers** (DOM events, shortcuts, +…) to **client-side actions** (clipboard copy, set property, run JS, …) +reading values from **arguments** (DOM properties, literals, JS expressions, +…), without a server round-trip when not needed. The motivating constraint +is the class of browser APIs that must run inside the user-gesture DOM +event handler that produced the event — clipboard, fullscreen, file +download, web share, … + +The API is in `com.vaadin.flow.component.trigger` and is designed for +extension: apps and add-ons add new trigger / action / argument types by +extending the abstract bases and pairing them with a JS handler registered +on `window.Vaadin.Flow.triggers`. + +## Motivation in a nutshell + +The Vaadin 8 trigger API had a clean three-shape model — `Trigger`, +`Action`, `Argument` — that solved this class of problems and the +adjacent class of "disable-on-click for a button triggered by a keyboard +shortcut" (the `dev.vaadin.com/ticket/8484` case): the click is destined +for the server, but the visual / state change has to happen *immediately* +on the client so a second click during the round-trip latency window +becomes impossible. + +Flow has every primitive needed to rebuild that model — `Element.executeJs`, +`@JsModule`, server-side `Signal`, `NodeFeature` for per-node storage, +the `window.Vaadin.Flow.` namespacing pattern — without the +GWT-era `TriggerSupport`-as-connector indirection. + +## Confirmed design decisions + +| Question | Decision | +| --- | --- | +| How are `Argument` values resolved when a trigger fires? | **Snapshot at trigger time.** The dispatcher walks arguments synchronously inside the DOM event handler and passes resolved values to actions. Signals are not in the v0 client runtime; a `SignalArgument` adapter is deferred. | +| Where do trigger/action/argument records live in the state tree? | **Per-host-element.** Each `Element` has a lazy `TriggerSupport` server-side feature holding its own id-keyed pools. Cross-element wiring works by carrying the target element as an `executeJs` parameter; the snapshot references it by index. | +| What use cases must the v0 slices validate end-to-end? | Clipboard copy, disable-on-shortcut, inline JS escape hatch, server-side `Runnable` action. | +| `Trigger.triggers(SerializableRunnable)` ergonomics? | **Keep the lambda overload** on `Trigger`. Internally it constructs a `ServerCallbackAction(handler)`. | +| Server-state mirroring of actions? | **Eager.** Actions that have a server-observable effect (e.g. `SetEnabledAction`) override `applyServerSideEffect()`. The mirror runs at the **start** of the same server cycle that processes the trigger event, **before** user-attached DOM event listeners fire — so listener code sees the post-action state and a second click during the latency window is a no-op locally. | +| Public API host type? | **`Component` only on the public surface.** Built-in classes (`ClickTrigger`, `ShortcutTrigger`, `JsTrigger`, `ClickAction`, `SetEnabledAction`, `PropertyArgument`, …) accept a `Component`. Element-level access is internal: `AbstractTrigger`'s protected constructor still takes either, for add-on infrastructure that legitimately works at the DOM level; `TriggerSupport.on(Element)` and `ConfigContext.referenceElement(Element)` live in the `internal` package. | + +## Architecture + +### Server + +- Public package: `com.vaadin.flow.component.trigger`. + - Interfaces: `Trigger`, `Action`, `Argument`. + - Abstract bases (extension points): `AbstractTrigger`, `AbstractAction`, + `AbstractArgument`. Each carries a **namespaced type id** + (`flow:click`, `vaadin:notification`, `myapp:double-tap`). Subclasses + override `buildClientConfig(ConfigContext)` to ship JSON config and + `applyServerSideEffect()` for the server mirror. + +- Internal package: `com.vaadin.flow.component.trigger.internal`. + - `TriggerSupport extends ServerSideFeature`: the per-element store. + Holds three id-keyed pools (triggers, actions, arguments) plus a list of + bindings `{triggerId, [actionIds…]}`. Implements `ConfigContext` so + subclasses can ask it to register arguments / element references by id. + - `ConfigContext`: tiny interface passed to `buildClientConfig` — + `registerArgument(Argument)` and `referenceElement(Element|Component)`. + - `ServerCallbackAction`: the sugar target for `Trigger.triggers(Runnable)`. + +- Three small framework registrations (same pattern as `SignalBindingFeature`): + - `NodeFeatures.TRIGGER_SUPPORT` constant. + - Factory registered in `NodeFeatureRegistry`. + - Slot added to `BasicElementStateProvider.features`. + +### Client + +- `flow-client/src/main/frontend/Triggers.ts` — imported from `Flow.ts` + so it ships in the boot bundle. +- Self-registers `window.Vaadin.Flow.triggers` with the public API + `registerTrigger / registerAction / registerArgument / bind / unbind`. +- Built-in factories registered at module load. Add-on `@JsModule`s + register their own custom types against the same registry. +- `bind(host, snapshot, extraElements?)` is **idempotent** — tracked per + host via a `WeakMap`; a second `bind` disposes the previous installation + before wiring the new one. (Per `DESIGN_GUIDELINES.md`.) +- The dispatcher walks bindings synchronously inside the DOM event handler + so user-gesture-only browser APIs (clipboard, fullscreen, share, …) + remain callable from actions. + +### Wire format + +A single JSON snapshot per host, sent through `Element.executeJs`: + +```jsonc +{ + "triggers": { "0": { "type": "flow:click", "config": { /* … */ } } }, + "actions": { "0": { "type": "flow:clipboard-copy", "config": { "text": 0 } } }, + "arguments": { "0": { "type": "flow:property", "config": { "property": "value", "element": 1 } } }, + "bindings": [ { "trigger": 0, "actions": [0] } ] +} +``` + +The executeJs call passes the host element as `this` and the snapshot as +`$0`. Secondary elements referenced by arguments/actions are passed as +extra positional parameters and appear in the snapshot as parameter +indices (`element: 1` means the second extra element, `0` means the +host itself). + +The snapshot is re-emitted on: +- Every binding mutation (collapsed via `StateTree.beforeClientResponse` + to one call per request cycle). +- Every host (re-)attach, so re-attaching to the DOM after a detach + doesn't drop the bindings. + +## Public API surface (sketch) + +```java +public interface Trigger extends Serializable { + Trigger triggers(Action... actions); + Trigger triggers(SerializableRunnable serverHandler); + void remove(); +} + +public interface Action extends Serializable {} +public interface Argument extends Serializable {} + +public abstract class AbstractTrigger implements Trigger { + protected AbstractTrigger(String typeId, Element host); + protected AbstractTrigger(String typeId, Component host); + public ObjectNode buildClientConfig(ConfigContext context); + public final Element getHost(); + public final String getTypeId(); +} + +public abstract class AbstractAction implements Action { + protected AbstractAction(String typeId); + public ObjectNode buildClientConfig(ConfigContext context); + public void applyServerSideEffect(); +} + +public abstract class AbstractArgument implements Argument { + protected AbstractArgument(String typeId, Class valueType); + public ObjectNode buildClientConfig(ConfigContext context); +} +``` + +## Slice plan + +Each slice is one PR-sized chunk: built-ins + unit tests + one IT. + +### Slice 1 — Clipboard copy (DONE) + +- **Triggers**: `ClickTrigger` (`flow:click`). +- **Arguments**: `PropertyArgument` (`flow:property`). +- **Actions**: `ClipboardCopyAction` (`flow:clipboard-copy`). +- **Server stub**: `ServerCallbackAction` (`flow:server-callback`) — class + in place so `Trigger.triggers(Runnable)` compiles; client handler ships + in slice 4. +- **IT**: `TriggerClipboardCopyIT` — button click copies textfield value; + stubs `navigator.clipboard.writeText` to avoid permission flakiness. +- **Validates**: Per-host snapshot + executeJs wire + idempotent bind + + user-gesture preservation + cross-element argument reference. + +### Slice 2 — Disable-on-shortcut (DONE) + +- **Triggers**: `ShortcutTrigger` (`flow:shortcut`, `Key` + `KeyModifier…`, + scoped to host via a capturing `keydown` listener). +- **Actions**: + - `ClickAction` (`flow:click`) — `element.click()` on a target. + - `SetEnabledAction` (`flow:set-enabled`, boolean) — toggles the + `disabled` attribute locally **and** mirrors server-side via + `applyServerSideEffect()` calling `Element.setEnabled(boolean)`. +- **Mirror plumbing**: `TriggerSupport` lazily registers a single + `ReturnChannelRegistration` per host (with + `DisabledUpdateMode.ALWAYS`); the bind call passes it to the client as + the last `executeJs` parameter, where it arrives as a callable + function. Each action factory receives a `notifyServer` callback that + closes over its own id. When `SetEnabledAction.run()` finishes its + local DOM change it invokes `notifyServer()`, which delivers + `[actionId]` to `dispatchMirror` on the server, which looks up the + action and calls `applyServerSideEffect()`. +- **Ordering note**: with `ClickAction` ahead of `SetEnabledAction` in + the binding (`(click, disable)`), `target.click()` dispatches the + click event while the button is still enabled — Flow's listener + queues that event. `SetEnabledAction` then disables the button + locally and queues a mirror notification through the return channel. + Server processes the click first (the user's `ClickListener` sees + `isEnabled() == true`); the mirror runs immediately after, + setting server state to disabled. The state-tree change syncs back + reaffirming the local disable. The reverse order + `(disable, click)` does **not** work: a browser blocks + `element.click()` on an already-disabled element, so the click + action becomes a no-op. The trigger API does not currently rewrite + user-chosen action order. +- **IT**: `Enter` shortcut on a `Div` form → click + disable on a + submit button. Asserts the click reached the server + (`"clicked, enabled=true"`) and the button is disabled + client-side after the trigger. +- **Validates**: trigger on one component / action on another; + end-to-end server mirroring via the return channel; the + disable-during-latency-window pattern (browser blocks a second + user click while the round-trip is in flight). + +### Slice 3 — Inline JS escape hatch (DONE) + +- **Triggers**: `JsTrigger` (`flow:js`) — expression evaluated with the + host element as {@code this} and {@code trigger} as the fire helper; + may return a cleanup function used on uninstall. +- **Actions**: `JsAction` (`flow:js`, varargs of `Argument`) — + expression evaluated with {@code argument(i)} resolving to the i-th + declared argument's current value at fire time. Declared arguments go + through the shared `ConfigContext.registerArgument(...)` path so they + dedupe with built-in arguments. +- **Arguments**: `JsArgument` (`flow:js`, valueType, expression) — + expression evaluated at the moment a trigger fires; its return value + is the argument. +- **Client wiring**: all three use `new Function(...)` (not `eval`) and + swallow compile/runtime exceptions to a `console.debug` so a broken + expression doesn't break the rest of the dispatch. +- **IT**: button click → `JsTrigger` fires → `JsAction` reads + `JsArgument`'s value and writes it into a span. Pure JS round trip with + no custom Java action class and no custom TS module. +- **Validates**: that add-on authors can ship a working custom type + without writing a TS module first. + +### Slice 4 — Server callback as an action (DONE) + +- **Action**: wire the existing `ServerCallbackAction` (`flow:server-callback`) + stubbed in slice 1. +- **Mechanism**: piggybacks on the per-host `ReturnChannelRegistration` + already in place from slice 2's mirror plumbing — no new round-trip + path, no UI-scoped helper. `ServerCallbackAction.applyServerSideEffect()` + invokes the wrapped `SerializableRunnable`; the client factory's + `run()` is a one-liner calling `notifyServer()`. The existing + `TriggerSupport.dispatchMirror` looks the action up by id and calls + `applyServerSideEffect()`. +- **No arguments to the `Runnable`** in v0. `Trigger.triggers(Runnable)` is + no-arg sugar; if a callback needs values, use a `@ClientCallable` on + a component directly, build a custom `Action` subclass, or carry the + context through server-side state. +- **IT**: button → `Trigger.triggers(() -> result.setText("server fired"))` + → IT asserts the result text updates. +- **Validates**: the degrade-to-round-trip path; the lambda overload + `Trigger.triggers(Runnable)`; that the per-host channel handles both + mirror notifications (slice 2) and server callbacks (slice 4) + without protocol changes. + +### Slice 5 — `SignalArgument` (DONE) + +- **Argument**: `SignalArgument(Signal)` (`flow:signal-value`). Reads + a server-side `Signal` and exposes its current value to trigger + actions, snapshot-style. +- **Mechanism**: at snapshot-build time the server reads + `signal.peek()` and ships the value directly inside the argument's + config (`{"value": }`). On construction the argument + subscribes to its `Signal` and on every change schedules the host's + next `beforeClientResponse` flush — same path `TriggerSupport` + already uses for `.triggers(…)` mutations. The client factory is + trivial: `read()` returns the value lifted from `config.value`. +- **Cleanup**: the signal subscription is anchored on the host's + state-node detach hook so it doesn't outlive its UI. +- **Use cases**: action needs a value from a `ValueSignal`, + `ComputedSignal`, or `SharedValueSignal` that isn't naturally + represented as a DOM property on any element (session state, + derived computations, multi-user collaborative state). +- **Why a dedicated type instead of "bind signal → property, then + `PropertyArgument`"**: ergonomic — saves creating a ghost property + whose only purpose is to relay the signal value into a trigger, and + makes the dependency explicit at the call site. +- **Snapshot semantics, not reactive composition.** This slice does + not introduce `FormatArgument`, `Argument.not`, `ComputedSignal`-of- + arguments etc. — those remain in "deferred". `SignalArgument` is a + thin reader, not a graph builder. +- **Tradeoff**: every signal change re-emits the snapshot for the + host. Fine for low-frequency UI/session signals; users binding a + rapidly-changing signal (mouse position, etc.) should reach for + `JsArgument` or a property binding instead. +- **IT**: button → `ClipboardCopyAction` reading a + `SignalArgument(sessionLocaleSignal)`. Mutate the signal, + click, assert clipboard receives the new value. +- **Validates**: signal value flows into snapshot; signal change + triggers re-emit; cleanup on detach. + +## Extending the API (apps and add-ons) + +1. Write a server class extending `AbstractTrigger` / `AbstractAction` / + `AbstractArgument` with a namespaced type id (`myapp:foo`). Override + `buildClientConfig(ConfigContext)` to ship config. If the action has + a server-observable effect, override `applyServerSideEffect()`. +2. Add `@JsModule("./my-trigger.js")` (or annotate `UI`) for a TS/JS + file that calls `window.Vaadin.Flow.triggers.registerAction( + "myapp:foo", factory)` at module load. +3. That's it. The framework's snapshot pipeline and dispatcher pick up + the new type by id. + +A `JsAction` / `JsArgument` (slice 3) covers the case where the add-on +author doesn't want a TS file at all. + +## Deferred / explicitly out of scope for v0 + +- **Argument composition** (`FormatArgument`, `Argument.not`, `ConditionalAction`, + `Argument.combine`, …). Straightforward to add once the core API is + proven; deliberately omitted from v0 so the public surface stays small. +- **Fluent shorthands on built-in components** (`button.triggerByPress(…)`, + `myButton.createDisableAction()`, `myButton.setShortcutKey(KeyCode.ENTER)`, + …). One-line sugar on top of the low-level API. Defer until the + low-level API stabilises so the shorthands don't ossify a wrong shape. +- **Touching `flow-client` GWT.** The original Vaadin 8 architecture + used GWT connectors for triggers/actions; the modern Flow client mixes + GWT Java (state-tree binding) with TypeScript modules + (`Geolocation.ts`, `PageVisibility.ts`). The trigger runtime lives in + the TS half, leaving the GWT side untouched. +- **Public access to the wire format.** Snapshot shape lives in the + internal package and can change without API breakage. + +## Lifecycle & ordering details + +- `TriggerSupport` is a `ServerSideFeature` — server-only, never serialises + itself through the standard `NodeChange` machinery. Wire updates go + through `executeJs`, not the state-tree sync. +- `Element.executeJs` defers until the element is attached, so the first + `bind` call after registration arrives at the right time without + manual attach handling. +- For **re-attach**, `TriggerSupport` adds a one-shot + `addAttachListener` on first use that re-emits the snapshot. The + client's `bind` is idempotent (disposes previous handlers first). +- Mutations are coalesced via + `StateTree.beforeClientResponse(stateNode, …)` so a burst of + `.triggers(…)` calls in one request produces a single `executeJs` + emit. +- Action server-side effects (slice 2+) run **before** user-attached + `DomEventListener`s in the same server cycle. This is the + load-bearing ordering for `SetEnabledAction` etc.: the server's view + of the component already reflects the action by the time application + code runs. + +## File map + +``` +flow-server/src/main/java/com/vaadin/flow/component/trigger/ + Trigger.java — interface + Action.java — interface + Argument.java — interface (generic) + AbstractTrigger.java — base + Element/Component constructors + AbstractAction.java — base + applyServerSideEffect hook + AbstractArgument.java — base + ClickTrigger.java — flow:click (slice 1) + PropertyArgument.java — flow:property (slice 1) + ClipboardCopyAction.java — flow:clipboard-copy (slice 1) + ShortcutTrigger.java — flow:shortcut (slice 2) + ClickAction.java — flow:click (slice 2) + SetEnabledAction.java — flow:set-enabled (slice 2) + JsTrigger.java — flow:js (slice 3) + JsAction.java — flow:js (slice 3) + JsArgument.java — flow:js (slice 3) + SignalArgument.java — flow:signal-value (slice 5) + internal/ + TriggerSupport.java — per-element ServerSideFeature + ConfigContext.java — passed to buildClientConfig + ServerCallbackAction.java — flow:server-callback (slice 4) + +flow-client/src/main/frontend/ + Triggers.ts — window.Vaadin.Flow.triggers runtime + +flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ + TriggerClipboardCopyView.java (slice 1) + TriggerShortcutDisableView.java (slice 2) + TriggerJsEscapeHatchView.java (slice 3) + TriggerServerCallbackView.java (slice 4) + TriggerSignalArgumentView.java (slice 5) +```