-
Notifications
You must be signed in to change notification settings - Fork 965
[lit-element] Declarative event emitters #3277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
many questions 😬 😅 |
@daKmoR How this is used depends on the context, but is exactly like built-in element event handler properties: Plain JS: const el = document.querySelector('my-element');
el.onFoo = fooHandler; lit-html, as usual: html`<my-element @foo=${fooHandler}></my-element>` lit-html, using the event handler property: html`<my-element .onFoo=${fooHandler}></my-element>` React: import {MyElement as MyCustomElement} from 'my-element';
import {createReactComponent} from 'lit-element/react';
MyElement = createReactComponent(MyElement);
const ParentComponent = (props) =>
<MyElement onFoo=${props.fooHandler}></MyElement>; |
That was my first reaction too, but I guess it's like ye-olde standard html event handler properties such as I'm guessing this would help with generating docs about an element (?) |
Yep. And help implement the |
At Vaadin we do have a strong need for for typed events. Here are some of our use cases: Events with generic typesAs an example, in render() {
return html`
<vaadin-grid
.items=${this.contacts}
@active-item-changed=${(e: GridActiveItemChangedEvent<Contact>) =>
this.currentContact = e.detail.value}
></vaadin-grid>
`;
} See also vaadin/vaadin-grid#2071 Events with same name but different payloadWe have several components with
So we can't declare See also runem/lit-analyzer#143 |
How about some prior art? StencilJS has for the same reasons an borrowing: export interface EventOptions {
/**
* A string custom event name to override the default.
*/
eventName?: string;
/**
* A Boolean indicating whether the event bubbles up through the DOM or not.
*/
bubbles?: boolean;
/**
* A Boolean indicating whether the event is cancelable.
*/
cancelable?: boolean;
/**
* A Boolean value indicating whether or not the event can bubble across the boundary between the shadow DOM and the regular DOM.
*/
composed?: boolean;
} To create a decorator // TODO: utilize TS function overloading stuff, to prevent using eventOptions twice
function event(eventOptionsOrName: EventOptions | string, eventOptions?: EventOptions) {
return (target, propKey) => {
// ... generate sth. like this.dispatchEvent() with above set options...
};
}
@event('selected')
emitSelectedEvent!: (payload?:EventPayload<SelectedEvent>) => EventEmitter<SelectedEvent> I think the bare minimum you will need for static type analysis and thus some code generation logic depending on the TypeScript AST is the actual |
I took a stab at seeing if we could declare the events as a Type parameter to the
type TypedEvent<T extends string, E extends Event> = {readonly type: T} & E;
// Union with void and assign to void to make this optional.
export class LitElement<DispatchableEvents extends TypedEvent<string, Event> | void = void> extends UpdatingElement {
// If the DispatchableEvents is a TypedEvent use the type, otherwise simply use the event as normal.
dispatchEvent(e: DispatchableEvents extends TypedEvent<infer T, infer E> ? TypedEvent<T, E> : Event): boolean {
// Propagate call to EventTarget superclass
return super.dispatchEvent(e);
}
...
} There is slight ergonomic issues in this solution which can be seen in how this would be used: type TestEvent = TypedEvent<'testEvent', CustomEvent<string>>;
type OtherTestEvent = TypedEvent<'otherTestEvent', MouseEvent>;
class TestElement extends LitElement<TestEvent | OtherTestEvent> {
emit() {
// This seems a little tedious since the type field is already set to 'testEvent' in the
// event constructor.
this.dispatchEvent({...new CustomEvent<string>('hello'), type: 'testEvent'})
// Expected Error: Argument of type 'Event' is not assignable to parameter of type
// 'TypedEvent<"testEvent", CustomEvent<string>> | TypedEvent<"otherTestEvent", MouseEvent>'.
this.dispatchEvent(new Event('notDeclared'));
}
} The main hurdle I had to overcome in this was the dom interface Event {
readonly type: string;
}
declare var Event: {
new(type: string, eventInitDict?: EventInit): Event;
}; Notice how the param In an ideal world the interface Event<Type extends string = string> {
readonly type: Type;
}
declare var Event: {
new <Type extends string = string>(type: Type, eventInitDict?: EventInit): Event<Type>;
}; |
If we could get the // No more need of this discriminated type intersection.
// type TypedEvent<T extends string, E extends Event> = {readonly type: T} & E;
// The types which the params extend become much more readable.
export class LitElement<DispatchableEvents extends Event | void = void> extends UpdatingElement {
// You can only call dispatchEvent with Events discriminated by string literals on member `type`.
// `string extends Type` checks if the Events is using string literal types.
dispatchEvent(e: DispatchableEvents extends Event<infer Type> ? string extends Type ? never : DispatchableEvents : Event): boolean {
return super.dispatchEvent(e);
} Looking at the usage, it also seems much more idiomatic (with one hard to understand error). class TestElement extends LitElement<CustomEvent<string, 'testEvent'> | Event<'otherEvent'>> {
emit() {
// Works as intended
this.dispatchEvent(new CustomEvent('testEvent', {detail: 'test'}));
this.dispatchEvent(new Event('otherEvent'));
// Compiler Error as intended
// Error: Type 'Event<"testEvent">' is missing the following properties from type 'CustomEvent<string, "testEvent">': detail, initCustomEvent
this.dispatchEvent(new Event('testEvent'))
// Error: Argument of type 'Event<"notDeclared">' is not assignable to parameter of type 'CustomEvent<string, "testEvent"> | Event<"otherEvent">'
this.dispatchEvent(new Event('notDeclared'))
}
}
class NoEventTypes extends LitElement {
emit() {
// All work as intended
this.dispatchEvent(new CustomEvent('testEvent', {detail: 'test'}));
this.dispatchEvent(new Event('otherEvent'));
this.dispatchEvent(new Event('testEvent'))
this.dispatchEvent(new Event('notDeclared'))
}
}
class EventTypesDontDeclareType extends LitElement<MouseEvent> {
emit() {
// Expected error here but the error message is unhelpful. Ideally the error would be in the untyped
// Event passed to the LitElement Type Params.
// Error: Argument of type 'MouseEvent' is not assignable to parameter of type 'never'.
this.dispatchEvent(new MouseEvent('hello'))
}
} Unfortunately I don't know how hard it would be to change the types found in https://github.com/microsoft/TypeScript/blob/master/lib/lib.dom.d.ts#L5285. Theoretically, we shouldn't get much friction if the type is correct (especially since it isn't a breaking change) but I'd have to check with the W3C spec to ensure the |
@landrito
The above reasons are good justifications for specific event emitters, i.e. dedicated functions emitting specialized events in a 1:1 mapping. This fosters abstraction of dispatching events and gives you also a single source of truth for them. It is not only a proven pattern for emitting events in other frameworks as well, but it combination with decorators this plays nicely along with the declarative approach that LitElement is currently using anyway ( Having that said, I think your approach could benefit event emitters as well, adding type-safety to them. |
Thanks for the detailed and thoughtful response @ChristianUlbrich!
Agreed.
Also agreed, I definitely misunderstood the problems this is issue is trying to address.
This was definitely my main goal with this; to see if there was a way to make the emission of an event to be type safe based on the event name used. |
To tie this back to the original proposal:
The interface class FooEvent extends Event {
readonly type!: 'foo';
constructor() {
super('foo');
}
} Furthermore I'd propose that the Decorator Options provided are specific to the Event in question. For example, the opts @ChristianUlbrich provided above could be updated to: export type EventName<E extends Event | void = void> =
E extends {type: infer TypeAttribute} ? TypeAttribute : string;
export interface EventOptions<E extends Event | void = void> {
/**
* A string custom event name to override the default.
*/
eventName?: EventName<E>
}
const opts: EventOptions<FooEvent> = {
// Expected Error: Type '"Bar"' is not assignable to type '"foo" | undefined'.
eventName: 'Bar'
} There is a problem though. Typescript doesn't seem to support generic decorators very well meaning the type of the decorators target is not propagated to the decorator function: microsoft/TypeScript#2607. |
This would really take Lit to the next level IMO. If we could declaratively generate statically typed |
This should be an RFC |
Hi All, I also think some resolution here (might be possible to do via mixin) would be very helpful!
Below i try to categorize the solution proposed here (prop decorators) according to the criteria above. prop decorators (this thread, here and here)
Are there realistic alternatives to this even? I have sketched up an approach in the past that uses a mixin + a class that enumerates the events as static fields. It avoids creating one property per Event and instead creates a static member that also allows enumeration per component class. Unfortunately it feels a bit more clunky that decorators. If this is even of interest i can create a gist.. |
This topic has come up again in our project (relatively large enterprise design system).
Edit: So I was wondering: |
I'm very much interested in this as well. It seems that you can either handle events through callback attributes/properties or through event listeners. I know there are arguments for using the event listener since you get some decoupling and the ability for multiple listeners but the fact that the callback attributes gets you declared events and strong typing seems to be a big reason to use those over emitting events. I know you've got to deal with the annoying |
Fwiw, I have an implementation of such a decorator at https://github.com/tbroyer/platformer/blob/main/packages/lit/event-handler.js, that also handles Assuming you don't want to support the attribute, this wouldn't be that much code. One thing to note: the event type would have to be an option to the decorator (for cases where it cannot directly be used as a JS identifier). It could also be applied to a setter for cases when the element needs to do things on setting. Fwiw, for details about how HTML event handlers work on builtin elements, I published a blog post about it: https://blog.ltgt.net/html-event-handlers/ Proof of concept implementationI'm using JS here because typing is a bit complex. export function event({ type } = {}) {
return function ({ get, set }, context) {
// TODO: check that the property is public, non-static, with a name that starts with 'on'
type ??= context.name.substring(2);
const listener = (e) => {
const callback = context.access.get();
if (callback == null || typeof callback !== "function") return;
let returnValue = callback.call(e.currentTarget, e);
if (returnValue === false) {
e.preventDefault();
}
};
let abortController;
return {
get,
set(value) {
// TODO: type coercion? (WebIDL's [LegacyTreatNonObjectAsNull] callback function)
if (value == null) {
abortController?.abort();
abortController = null;
} else if (abortController == null) {
abortController = new AbortController();
this.addEventListener(type, listener, { signal: abortController.signal });
}
}
}
}
} |
It would be convenient for ergonomics, documentation, React integration, and transpilers to be able to declare what events an element fires.
We could add a decorator (and static block for plain JS, like
properties
) that allows this by decorating event handler properties. The decorator would create accessors that useaddEventListener
under the hood.TypeScript:
JavaScript:
See some similar previous discussion here: lit/lit-element#808
The text was updated successfully, but these errors were encountered: