-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
[FEATURE] Implement on
modifier.
#17960
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,262 @@ | ||
import { assert } from '@ember/debug'; | ||
import { DEBUG } from '@glimmer/env'; | ||
import { Opaque, Simple } from '@glimmer/interfaces'; | ||
import { Tag } from '@glimmer/reference'; | ||
import { Arguments, CapturedArguments, ModifierManager } from '@glimmer/runtime'; | ||
import { Destroyable } from '@glimmer/util'; | ||
import buildUntouchableThis from '../utils/untouchable-this'; | ||
|
||
const untouchableContext = buildUntouchableThis('`on` modifier'); | ||
|
||
/* | ||
Internet Explorer 11 does not support `once` and also does not support | ||
passing `eventOptions`. In some situations it then throws a weird script | ||
error, like: | ||
|
||
``` | ||
Could not complete the operation due to error 80020101 | ||
``` | ||
|
||
This flag determines, whether `{ once: true }` and thus also event options in | ||
general are supported. | ||
*/ | ||
const SUPPORTS_EVENT_OPTIONS = (() => { | ||
try { | ||
const div = document.createElement('div'); | ||
let counter = 0; | ||
div.addEventListener('click', () => counter++, { once: true }); | ||
|
||
let event; | ||
if (typeof Event === 'function') { | ||
event = new Event('click'); | ||
} else { | ||
event = document.createEvent('Event'); | ||
event.initEvent('click', true, true); | ||
} | ||
|
||
div.dispatchEvent(event); | ||
div.dispatchEvent(event); | ||
|
||
return counter === 1; | ||
} catch (error) { | ||
return false; | ||
} | ||
})(); | ||
|
||
export class OnModifierState { | ||
public tag: Tag; | ||
public element: Element; | ||
public args: CapturedArguments; | ||
public eventName!: string; | ||
public callback!: EventListener; | ||
private userProvidedCallback!: EventListener; | ||
public once?: boolean; | ||
public passive?: boolean; | ||
public capture?: boolean; | ||
public options?: AddEventListenerOptions; | ||
public shouldUpdate = true; | ||
|
||
constructor(element: Element, args: CapturedArguments) { | ||
this.element = element; | ||
this.args = args; | ||
this.tag = args.tag; | ||
} | ||
|
||
updateFromArgs() { | ||
let { args } = this; | ||
|
||
let { once, passive, capture }: AddEventListenerOptions = args.named.value(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, the RFC specifically states the opposite (that passing other things is "ignored"): From here:
I don't feel super strongly so I just followed the RFC on this. @pzuraq - Do you think we should change to assert? |
||
if (once !== this.once) { | ||
this.once = once; | ||
this.shouldUpdate = true; | ||
} | ||
|
||
if (passive !== this.passive) { | ||
this.passive = passive; | ||
this.shouldUpdate = true; | ||
} | ||
|
||
if (capture !== this.capture) { | ||
this.capture = capture; | ||
this.shouldUpdate = true; | ||
} | ||
|
||
let options: AddEventListenerOptions; | ||
if (once || passive || capture) { | ||
options = this.options = { once, passive, capture }; | ||
} else { | ||
this.options = undefined; | ||
} | ||
|
||
assert( | ||
'You must pass a valid DOM event name as the first argument to the `on` modifier', | ||
args.positional.at(0) !== undefined && typeof args.positional.at(0).value() === 'string' | ||
rwjblue marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
let eventName = args.positional.at(0).value() as string; | ||
if (eventName !== this.eventName) { | ||
this.eventName = eventName; | ||
this.shouldUpdate = true; | ||
} | ||
|
||
assert( | ||
'You must pass a function as the second argument to the `on` modifier', | ||
args.positional.at(1) !== undefined && typeof args.positional.at(1).value() === 'function' | ||
); | ||
let userProvidedCallback = args.positional.at(1).value() as EventListener; | ||
if (userProvidedCallback !== this.userProvidedCallback) { | ||
this.userProvidedCallback = userProvidedCallback; | ||
this.shouldUpdate = true; | ||
} | ||
|
||
assert( | ||
`You can only pass two positional arguments (event name and callback) to the \`on\` modifier, but you provided ${ | ||
args.positional.length | ||
}. Consider using the \`fn\` helper to provide additional arguments to the \`on\` callback.`, | ||
args.positional.length === 2 | ||
); | ||
|
||
let needsCustomCallback = | ||
(SUPPORTS_EVENT_OPTIONS === false && once) /* needs manual once implementation */ || | ||
(DEBUG && passive) /* needs passive enforcement */; | ||
|
||
if (this.shouldUpdate) { | ||
if (needsCustomCallback) { | ||
let callback = (this.callback = function(this: Element, event) { | ||
if (DEBUG && passive) { | ||
event.preventDefault = () => { | ||
assert( | ||
`You marked this listener as 'passive', meaning that you must not call 'event.preventDefault()': \n\n${userProvidedCallback}` | ||
); | ||
}; | ||
} | ||
|
||
if (!SUPPORTS_EVENT_OPTIONS && once) { | ||
removeEventListener(this, eventName, callback, options); | ||
} | ||
return userProvidedCallback.call(untouchableContext, event); | ||
}); | ||
} else if (DEBUG) { | ||
// prevent the callback from being bound to the element | ||
this.callback = userProvidedCallback.bind(untouchableContext); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! 👍 |
||
} else { | ||
this.callback = userProvidedCallback; | ||
} | ||
} | ||
} | ||
|
||
destroy() { | ||
let { element, eventName, callback, options } = this; | ||
|
||
removeEventListener(element, eventName, callback, options); | ||
} | ||
} | ||
|
||
let adds = 0; | ||
let removes = 0; | ||
|
||
function removeEventListener( | ||
element: Element, | ||
eventName: string, | ||
callback: EventListener, | ||
options?: AddEventListenerOptions | ||
): void { | ||
removes++; | ||
|
||
if (SUPPORTS_EVENT_OPTIONS) { | ||
// when options are supported, use them across the board | ||
element.removeEventListener(eventName, callback, options); | ||
} else if (options !== undefined && options.capture) { | ||
// used only in the following case: | ||
// | ||
// `{ once: true | false, passive: true | false, capture: true } | ||
// | ||
// `once` is handled via a custom callback that removes after first | ||
// invocation so we only care about capture here as a boolean | ||
element.removeEventListener(eventName, callback, true); | ||
} else { | ||
// used only in the following cases: | ||
// | ||
// * where there is no options | ||
// * `{ once: true | false, passive: true | false, capture: false } | ||
element.removeEventListener(eventName, callback); | ||
} | ||
} | ||
|
||
function addEventListener( | ||
element: Element, | ||
eventName: string, | ||
callback: EventListener, | ||
options?: AddEventListenerOptions | ||
): void { | ||
adds++; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assuming these aren't in DEBUGs because you wanted to run these tests against IE? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, exactly. We can ultimately remove if we care, but it's very very cheap and almost certainly won't matter. |
||
|
||
if (SUPPORTS_EVENT_OPTIONS) { | ||
// when options are supported, use them across the board | ||
element.addEventListener(eventName, callback, options); | ||
} else if (options !== undefined && options.capture) { | ||
// used only in the following case: | ||
// | ||
// `{ once: true | false, passive: true | false, capture: true } | ||
// | ||
// `once` is handled via a custom callback that removes after first | ||
// invocation so we only care about capture here as a boolean | ||
element.addEventListener(eventName, callback, true); | ||
} else { | ||
// used only in the following cases: | ||
// | ||
// * where there is no options | ||
// * `{ once: true | false, passive: true | false, capture: false } | ||
element.addEventListener(eventName, callback); | ||
} | ||
} | ||
|
||
export default class OnModifierManager implements ModifierManager<OnModifierState, Opaque> { | ||
public SUPPORTS_EVENT_OPTIONS: boolean = SUPPORTS_EVENT_OPTIONS; | ||
|
||
get counters() { | ||
return { adds, removes }; | ||
} | ||
|
||
create(element: Simple.Element | Element, _state: Opaque, args: Arguments) { | ||
const capturedArgs = args.capture(); | ||
|
||
return new OnModifierState(<Element>element, capturedArgs); | ||
} | ||
|
||
getTag({ tag }: OnModifierState): Tag { | ||
return tag; | ||
} | ||
|
||
install(state: OnModifierState) { | ||
state.updateFromArgs(); | ||
|
||
let { element, eventName, callback, options } = state; | ||
|
||
addEventListener(element, eventName, callback, options); | ||
|
||
state.shouldUpdate = false; | ||
} | ||
|
||
update(state: OnModifierState) { | ||
// stash prior state for el.removeEventListener | ||
let { element, eventName, callback, options } = state; | ||
|
||
state.updateFromArgs(); | ||
|
||
if (!state.shouldUpdate) { | ||
return; | ||
} | ||
|
||
// use prior state values for removal | ||
removeEventListener(element, eventName, callback, options); | ||
|
||
// read updated values from the state object | ||
addEventListener(state.element, state.eventName, state.callback, state.options); | ||
|
||
state.shouldUpdate = false; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I'm reading this correctly, once the modifier is installed, it will only ever update to a new listener, but will never be uninstalled. I can't think of a scenario, where the modifier would need to be uninstalled from an element that does not get removed from the DOM afterwards, but I think @chancancode made a case for this once on Discord. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @buschtoens - We definitely cleanup (see There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I see! So There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ya, this is one way the "internal" modifiers differ from the public API. The public API effectively does the same thing (the |
||
|
||
getDestructor(state: Destroyable) { | ||
return state; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import { HAS_NATIVE_PROXY } from '@ember/-internals/utils'; | ||
import { assert } from '@ember/debug'; | ||
import { DEBUG } from '@glimmer/env'; | ||
|
||
export default function buildUntouchableThis(source: string): null | object { | ||
let context: null | object = null; | ||
if (DEBUG && HAS_NATIVE_PROXY) { | ||
let assertOnProperty = (property: string | number | symbol) => { | ||
assert( | ||
`You accessed \`this.${String( | ||
property | ||
)}\` from a function passed to the ${source}, but the function itself was not bound to a valid \`this\` context. Consider updating to usage of \`@action\`.` | ||
); | ||
}; | ||
|
||
context = new Proxy( | ||
{}, | ||
{ | ||
get(_target: {}, property: string | symbol) { | ||
assertOnProperty(property); | ||
}, | ||
|
||
set(_target: {}, property: string | symbol) { | ||
assertOnProperty(property); | ||
|
||
return false; | ||
}, | ||
|
||
has(_target: {}, property: string | symbol) { | ||
assertOnProperty(property); | ||
|
||
return false; | ||
}, | ||
} | ||
); | ||
} | ||
|
||
return context; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yaaaaas