Gathering detailed insights and metrics for ember-modifier
Gathering detailed insights and metrics for ember-modifier
Gathering detailed insights and metrics for ember-modifier
Gathering detailed insights and metrics for ember-modifier
npm install ember-modifier
Module System
Min. Node Version
Typescript Support
Node Version
NPM Version
108 Stars
961 Commits
32 Forks
6 Watching
12 Branches
26 Contributors
Updated on 31 Oct 2024
TypeScript (73.07%)
JavaScript (23.55%)
HTML (3.06%)
CSS (0.2%)
Handlebars (0.12%)
Cumulative downloads
Total Downloads
Last day
-34.3%
19,775
Compared to previous day
Last week
-11.5%
125,893
Compared to previous week
Last month
13.4%
597,054
Compared to previous month
Last year
10.2%
8,974,292
Compared to previous year
4
1
26
This addon provides an API for authoring element modifiers in Ember. It mirrors Ember's helper API, with variations for writing both simple function-based modifiers and more complicated class-based modifiers.
NOTE: this is the README for the v4 release. For the v3 README, see here.
This project follows the current draft of the Semantic Versioning for TypeScript Types proposal.
-private
module are publicember install ember-modifier
Modifiers are a basic primitive for interacting with the DOM in Ember. For
example, Ember ships with a built-in modifier, {{on}}
:
1<button {{on "click" @onClick}}> 2 {{@text}} 3</button>
All modifiers get applied to elements directly this way (if you see a similar value that isn't in an element, it is probably a helper instead), and they are passed the element when applying their effects.
Conceptually, modifiers take tracked, derived state, and turn it into some sort of side effect related in some way to the DOM element they are applied to.
A "side effect" is something that happens in programming all the time. Here's an example of one in an Ember component that attempts to make a button like in the first example, but without modifiers:
1// 🛑 DO NOT COPY THIS 🛑 2import Component from '@glimmer/component'; 3 4export default class MyButton extends Component { 5 get setupEventHandler() { 6 document.querySelector('#my-button').addEventListener(this.args.onClick); 7 8 return undefined; 9 } 10}
1<button id="#my-button"> 2 {{this.setupEventHandler}} 3 4 {{@text}} 5</button>
We can see by looking at the setupEventListener
getter that it isn't actually
returning a value. Instead, it always returns undefined
. However, it also adds
the @onClick
argument as an event listener to the button in the template
when the getter is run, as the template is rendering, which is a side effect
Unmanaged side effects can make code very difficult to reason about, since any function could be updating a value elsewhere. In fact, the code above is very buggy:
@onClick
argument ever changes, it won't remove the old event
listener, it'll just keep adding new ones.However, there are lots of times where its difficult to write code that doesn't have side effects. Sometimes it would mean having to rewrite a large portion of an application. Sometimes, like in the case of modifying DOM, there isn't a clear way to do it at all with just getters and components.
This is where modifiers come in. Modifiers exist as a way to bridge the gap between derived state and side effects in way that is contained and consistent, so that users of a modifier don't have to think about them.
Let's look again at our original example:
1<button {{on "click" @onClick}}> 2 {{@text}} 3</button>
We can see pretty clearly from this template that Ember will:
<button>
element@text
argument to that button@onClick
argumentIf @text
or @onClick
ever change, Ember will keep everything in sync for us.
We don't ever have to manually set element.textContent
or update anything
ourselves. In this way, we can say the template is declarative - it tells
Ember what we want the output to be, and Ember handles all of the bookkeeping
itself.
Here's how we could implement the {{on}}
modifier so that it always keeps
things in sync correctly:
1import { modifier } from 'ember-modifier'; 2 3export default modifier((element, [eventName, handler]) => { 4 element.addEventListener(eventName, handler); 5 6 return () => { 7 element.removeEventListener(eventName, handler); 8 } 9});
Here, we setup the event listener using the positional parameters passed to the
modifier. Then, we return a destructor - a function that undoes our setup,
and is effectively the opposite side effect. This way, if the @onClick
handler ever changes, we first teardown the first event listener we added -
leaving the element in its original state before the modifier ever ran - and
then setup the new handler.
This is what allows us to treat the {{on}}
modifier as if it were just like
the {{@text}}
value we put in the template. While it is side effecting, it
knows how to setup and teardown that side effect and manage its state. The side
effect is contained - it doesn't escape into the rest of our application, it
doesn't cause other unrelated changes, and we can think about it as another
piece of declarative, derived state. Just another part of the template!
In general, when writing modifiers, especially general purpose/reusable modifiers, they should be designed with this in mind. Which specific effects are they trying to accomplish, how to manage them effectively, and how to do it in a way that is transparent to the user of the modifier.
This addon does not provide any modifiers out of the box. Instead, this library allows you to write your own. There are two ways to write modifiers:
These are analogous to Ember's Helper APIs, helper
and Helper
.
modifier
is an API for writing simple modifiers. For instance, you could
implement Ember's built-in {{on}}
modifier like so with modifier
:
1// /app/modifiers/on.js 2import { modifier } from 'ember-modifier'; 3 4export default modifier((element, [eventName, handler]) => { 5 element.addEventListener(eventName, handler); 6 7 return () => { 8 element.removeEventListener(eventName, handler); 9 } 10});
Function-based modifiers consist of a function that receives:
element
1modifier((element, positional, named) => { /* */ });
This function runs the first time when the element the modifier was applied to is inserted into the DOM, and it autotracks while running. Any tracked values that it accesses will be tracked, including the arguments it receives, and if any of them changes, the function will run again.1
The modifier can also optionally return a destructor. The destructor function will be run just before the next update, and when the element is being removed entirely. It should generally clean up the changes that the modifier made in the first place.
To create a modifier (and a corresponding integration test), run:
ember g modifier scroll-top
For example, if you wanted to implement your own scrollTop
modifier (similar
to this), you may do something like this:
1// app/modifiers/scroll-top.js 2import { modifier } from 'ember-modifier'; 3 4export default modifier((element, [scrollPosition]) => { 5 element.scrollTop = scrollPosition; 6})
1<div class="scroll-container" {{scroll-top @scrollPosition}}> 2 {{yield}} 3</div>
If the functionality you add in the modifier needs to be torn down when the element is removed, you can return a function for the teardown method.
For example, if you wanted to have your elements dance randomly on the page
using setInterval
, but you wanted to make sure that was canceled when the
element was removed, you could do:
1// app/modifiers/move-randomly.js 2import { modifier } from 'ember-modifier'; 3 4const { random, round } = Math; 5 6export default modifier(element => { 7 const id = setInterval(() => { 8 const top = round(random() * 500); 9 const left = round(random() * 500); 10 element.style.transform = `translate(${left}px, ${top}px)`; 11 }, 1000); 12 13 return () => clearInterval(id); 14}); 15
1<button {{move-randomly}}> 2 {{yield}} 3</button>
Ember Inspector supports showing modifiers. For Function-Based Modifiers it shows the function name as modifier name. By default arrow functions or unamed functions will be shown as <unknown>. To have a name shown in the inspector use a named function or pass an extra option.
1export default modifier(element => { 2 ... 3}, { name: 'my-fn-modifier' });
Sometimes you may need to do something more complicated than what can be handled by function-based modifiers. For instance:
In these cases, you can use a class-based modifier instead. Here's how you
would implement the {{on}}
modifier with a class:
1import Modifier from 'ember-modifier'; 2import { registerDestructor } from '@ember/destroyable'; 3 4function cleanup(instance: OnModifier) { 5 let { element, event, handler } = instance; 6 7 if (element && event && handler) { 8 element.removeEventListener(event, handler); 9 10 instance.element = null; 11 instance.event = null; 12 instance.handler = null; 13 } 14} 15 16export default class OnModifier extends Modifier { 17 element = null; 18 event = null; 19 handler = null; 20 21 modify(element, [event, handler]) { 22 this.addEventListener(element, event, handler); 23 registerDestructor(this, cleanup) 24 } 25 26 // methods for reuse 27 addEventListener = (element, event, handler) => { 28 // Store the current element, event, and handler for when we need to remove 29 // them during cleanup. 30 this.element = element; 31 this.event = event; 32 this.handler = handler; 33 34 element.addEventListener(event, handler); 35 }; 36}
While this is slightly more complicated than the function-based version, but that complexity comes along with much more control.
As with function-based modifiers, the lifecycle hooks of class modifiers are tracked. When they run, then any values they access will be added to the modifier, and the modifier will update if any of those values change.
To create a modifier (and a corresponding integration test), run:
ember g modifier scroll-top --class
For example, let's say you want to implement your own {{scroll-position}}
modifier (similar to this).
This modifier can be attached to any element and accepts a single positional
argument. When the element is inserted, and whenever the argument is updated, it
will set the element's scrollTop
property to the value of its argument.
(Note that this example does not require the use of a class, and could be implemented equally well with a function-based modifier!)
1// app/modifiers/scroll-position.js 2import Modifier from 'ember-modifier'; 3 4export default class ScrollPositionModifier extends Modifier { 5 modify(element, [scrollPosition], { relative }) { 6 if(relative) { 7 element.scrollTop += scrollPosition; 8 } else { 9 element.scrollTop = scrollPosition; 10 } 11 } 12}
Usage:
1{{!-- app/components/scroll-container.hbs --}} 2 3<div 4 class="scroll-container" 5 style="width: 300px; heigh: 300px; overflow-y: scroll" 6 {{scroll-position this.scrollPosition relative=false}} 7> 8 {{yield this.scrollToTop}} 9</div>
1// app/components/scroll-container.js 2 3import Component from '@glimmer/component'; 4import { tracked } from '@glimmer/tracking'; 5import { action } from '@ember/object'; 6 7export default class ScrollContainerComponent extends Component { 8 @tracked scrollPosition = 0; 9 10 @action scrollToTop() { 11 this.scrollPosition = 0; 12 } 13}
1{{!-- app/templates/application.hbs --}} 2 3<ScrollContainer as |scroll|> 4 A lot of content... 5 6 <button {{on "click" scroll}}>Back To Top</button> 7</ScrollContainer>
If the functionality you add in the modifier needs to be torn down when the
modifier is removed, you can use registerDestructor
from @ember/destroyable
.
For example, if you want to have your elements dance randomly on the page using
setInterval
, but you wanted to make sure that was canceled when the modifier
was removed, you could do this:
1// app/modifiers/move-randomly.js 2 3import Modifier from 'ember-modifier'; 4import { registerDestructor } from '@ember/destroyable' 5 6const { random, round } = Math; 7const DEFAULT_DELAY = 1000; 8 9function cleanup(instance) { 10 if (instance.setIntervalId !== null) { 11 clearInterval(instance.setIntervalId); 12 instance.setIntervalId = null; 13 } 14} 15 16export default class MoveRandomlyModifier extends Modifier { 17 element = null; 18 setIntervalId = null; 19 20 constructor(owner, args) { 21 super(owner, args); 22 registerDestructor(this, cleanup); 23 } 24 25 modify(element, _, { delay }) { 26 // Save off the element the first time for convenience with #moveElement 27 if (!this.element) { 28 this.element = element; 29 } 30 31 // Reset from any previous state. 32 cleanup(this); 33 34 this.setIntervalId = setInterval(this.#moveElement, delay ?? DEFAULT_DELAY); 35 } 36 37 #moveElement = (element) => { 38 let top = round(random() * 500); 39 let left = round(random() * 500); 40 this.element.style.transform = `translate(${left}px, ${top}px)`; 41 }; 42}
Usage:
1<div {{move-randomly}}> 2 Catch me if you can! 3</div>
You can also use services into your modifier, just like any other class in Ember.
For example, suppose you wanted to track click events with ember-metrics
:
1// app/modifiers/track-click.js 2 3import { inject as service } from '@ember/service'; 4import Modifier from 'ember-modifier'; 5import { registerDestructor } from '@ember/destroyable'; 6 7function cleanup(instance) { 8 instance.element?.removeEventListener('click', instance.onClick, true); 9} 10 11export default class TrackClick extends Modifier { 12 @service metrics; 13 14 constructor(owner, args) { 15 super(owner, args); 16 registerDestructor(this, this.cleanup); 17 } 18 19 modify(element, [eventName], options) { 20 this.element = element; 21 this.eventName = eventName; 22 this.options = options; 23 24 this.cleanup(); 25 element.addEventListener('click', this.onClick, true); 26 } 27 28 onClick = () => { 29 this.metrics.trackEvent(this.eventName, this.options); 30 }; 31}
Usage:
1<button {{track-click "like-button-click" page="some page" title="some title"}}> 2 Click Me! 3</button>
constructor(owner, args)
super(...arguments)
before performing other initialization.modify(element, positionalArgs, namedArgs)
Both the function- and class-based APIs can be used with TypeScript!
Before checking out the Examples with Typescript below, there is an important caveat you should understand about type safety!
There are, today, two basic approaches you can take to dealing with your modifier's arguments and element in a type safe way:
assert
calls to make your internal implementation safe.If you have a code base which is strictly typed from end to end, including with template type checking via Glint, then (1) is a great choice. If you have a mixed code base, or are publishing an addon for others to use, then it's usually best to do both (1) and (2)!
To handle runtime checking, for non-type-checked templates (including projects not yet using Glint or supporting external callers), you should act as though the arguments passed to your modifier can be anything. They’re typed as unknown
by default, which means by default TypeScript will require you to work out the type passed to you at runtime. For example, with the ScrollPositionModifier
shown above, you can combine TypeScript’s type narrowing with the default types for the class to provide runtime errors if the caller passes the wrong types, while providing safety throughout the rest of the body of the modifier. Here, modify
would be guaranteed to have the correct types for scrollPosition
and relative
:
1import Modifier from 'ember-modifier'; 2import { assert } from '@ember/debug'; 3 4export class ScrollPositionModifier extends Modifier { 5 modify(element, [scrollPosition], { relative }) { 6 assert(, 7 `first argument to 'scroll-position' must be a number, but ${scrollPosition} was ${typeof scrollPosition}`, 8 typeof scrollPosition === "number" 9 ); 10 11 assert( 12 `'relative' argument to 'scroll-position' must be a boolean, but ${relative} was ${typeof relative}`, 13 typeof relative === "boolean" 14 ); 15 16 if (relative) { 17 element.scrollTop += scrollPosition; 18 } else { 19 element.scrollTop = scrollPosition; 20 } 21 } 22}
If you were writing for a fully-typed context, you can define your Modifier
with a Signature
interface, similar to the way you would define your signature for a Glimmer Component:
1// app/modifiers/scroll-position.ts 2import Modifier from 'ember-modifier'; 3 4interface ScrollPositionModifierSignature { 5 Args: { 6 Positional: [number]; 7 Named: { 8 relative: boolean; 9 }; 10 }; 11} 12 13export default class ScrollPositionModifier 14 extends Modifier<ScrollPositionModifierSignature> { 15 modify(element, [scrollPosition], { relative }) { 16 if (relative) { 17 element.scrollTop += scrollPosition; 18 } else { 19 element.scrollTop = scrollPosition; 20 } 21 } 22}
Besides supporting integration with Glint, this also provides nice hooks for documentation tooling. Note, however, that it can result in much worse feedback in tests or at runtime if someone passes the wrong kind of arguments to your modifier and you haven't included assertions: users who pass the wrong thing will just have the modifier fail. For example, if you fail to pass the positional argument, scrollPosition
would simply be undefined
, and then element.scrollTop
could end up being set to NaN
. Whoops! For that reason, if your modifier will be used by non-TypeScript consumers, you should both publish the types for it and add dev-time assertions:
1// app/modifiers/scroll-position.ts 2import Modifier from 'ember-modifier'; 3 4interface ScrollPositionModifierSignature { 5 Args: { 6 Positional: [scrollPosition: number]; 7 Named: { 8 relative: boolean; 9 }; 10 }; 11 Element: Element; // not required: it'll be set by default 12} 13 14export default class ScrollPositionModifier 15 extends Modifier<ScrollPositionModifierSignature> { 16 modify(element, [scrollPosition], { relative }) { 17 assert(, 18 `first argument to 'scroll-position' must be a number, but ${scrollPosition} was ${typeof scrollPosition}`, 19 typeof scrollPosition === "number" 20 ); 21 22 assert( 23 `'relative' argument to 'scroll-position' must be a boolean, but ${relative} was ${typeof relative}`, 24 typeof relative === "boolean" 25 ); 26 27 if (relative) { 28 element.scrollTop += scrollPosition; 29 } else { 30 element.scrollTop = scrollPosition; 31 } 32 } 33}
Signature
typeThe Signature
for a modifier is the combination of the positional and named arguments it receives and the element to which it may be applied.
1interface Signature { 2 Args: { 3 Named: { 4 [argName: string]: unknown; 5 }; 6 Positional: unknown[]; 7 }; 8 Element: Element; 9}
When writing a signature yourself, all of those are optional: the types for modifiers will fall back to the correct defaults of Element
, an object for named arguments, and an array for positional arguments. You can apply a signature when defining either a function-based or a class-based modifier.
In a function-based modifier, the callback arguments will be inferred from the signature, so you do not need to specify the types twice:
1interface MySignature { 2 Element: HTMLMediaElement; 3 Args: { 4 Named: { 5 when: boolean; 6 }; 7 Positional: []; 8 }; 9} 10 11const play = modifier<MySignature>((el, _, { when: shouldPlay }) => { 12 if (shouldPlay) { 13 el.play(); 14 } else { 15 el.pause(); 16 } 17})
You never need to specify a signature in this way for a function-based modifier: you can simply write the types inline instead:
1const play = modifier( 2 (el: HTMLMediaElement, _: [], { when: shouldPlay }: { when: boolean}) => { 3 if (shouldPlay) { 4 el.play(); 5 } else { 6 el.pause(); 7 } 8 } 9);
However, the explicit modifier<Signature>(...)
form is tested to keep working, since it can be useful for documentation!
The same basic approach works with a class-based modifier:
1interface MySignature { 2 // ... 3} 4 5export default class MyModifier extends Modifier<MySignature> { 6 // ... 7}
In that case, the element
and args
will always have the right types throughout the body. Since the type of args
in the constructor are derived from the signature, you can use the ArgsFor
type helper to avoid having to write the type out separately:
1import Modifier, { ArgsFor } from 'ember-modifier'; 2 3interface MySignature { 4 // ... 5} 6 7export default class MyModifier extends Modifier<MySignature> { 8 constructor(owner: unknown, args: ArgsFor<MySignature>) { 9 // ... 10 } 11}
ArgsFor
isn't magic: it just takes the Args
from the Signature
you provide and turns it into the right shape for the constructor: the Named
type ends up as the named
field and the Positional
type ends up as the type for args.positional
, so you could write it out yourself if you preferred:
1import Modifier from 'ember-modifier'; 2 3interface MySignature { 4 // ... 5} 6 7export default class MyModifier extends Modifier<MySignature> { 8 constructor( 9 owner: unknown, 10 args: { 11 named: MySignature['Args']['Named']; 12 positional: MySignature['Args']['Positional']; 13 } 14 ) { 15 // ... 16 } 17}
Let’s look at a variant of the move-randomly
example from above, implemented in TypeScript, and now requiring a named argument, the maximum offset. Using the recommended combination of types and runtime type-checking, it would look like this:
1// app/modifiers/move-randomly.js 2import { modifier } from 'ember-modifier'; 3import { assert } from '@ember/debug'; 4 5const { random, round } = Math; 6 7export default modifier( 8 (element: HTMLElement, _: [], named: { maxOffset: number } 9) => { 10 assert( 11 'move-randomly can only be installed on HTML elements!', 12 element instanceof HTMLElement 13 ); 14 15 const { maxOffset } = named; 16 assert( 17 `The 'max-offset' argument to 'move-randomly' must be a number, but was ${typeof maxOffset}`, 18 typeof maxOffset === "number" 19 ); 20 21 const id = setInterval(() => { 22 const top = round(random() * maxOffset); 23 const left = round(random() * maxOffset); 24 element.style.transform = `translate(${left}px, ${top}px)`; 25 }, 1000); 26 27 return () => clearInterval(id); 28});
A few things to notice here:
TypeScript correctly infers the base types of the arguments for the function passed to the modifier; you don't need to specify what element
or positional
or named
are unless you are doing like we are in this example and providing a usefully more-specific type to callers.
If we returned a teardown function which had the wrong type signature, that would also be an error.
If we return a value instead of a function, for example:
1export default modifier((element, _, named) => { 2 // ... 3 4 return id; 5});
TypeScript will report:
Type 'Timeout' is not assignable to type 'void | Teardown'.
Likewise, if we return a function with the wrong signature, we will see the same kinds of errors. If we expected to receive an argument in the teardown callback, like this:
1export default modifier((element, _, named) => { 2 // 3 4 return (interval: number) => clearTimeout(interval); 5});
TypeScript will report:
Type '(interval: number) => void' is not assignable to type 'void | Teardown'.
To support correctly typing args
in the constructor
for the case where you do runtime type checking, we supply an ArgsFor
type utility. (This is useful because the Signature
type, matching Glimmer Component and other "invokable" items in Ember/Glimmer, has capital letters for the names of the types, while args.named
and args.positional
are lower-case.) Here’s how that would look with a fully typed modifier that alerts "This is a typesafe modifier!" an amount of time after receiving arguments that depends on the length of the first argument and an optional multiplier (a nonsensical thing to do, but one that illustrates a fully type-safe class-based modifier):
1import Modifier, { ArgsFor, PositionalArgs, NamedArgs } from 'ember-modifier'; 2import { assert } from '@ember/debug'; 3 4interface NeatSignature { 5 Args: { 6 Named: { 7 multiplier?: number; 8 }; 9 Positional: [string]; 10 } 11} 12 13 14function cleanup(instance: Neat) => { 15 if (instance.interval) { 16 clearInterval(instance.interval); 17 } 18} 19 20export default class Neat extends Modifier<NeatSignature> { 21 interval?: number; 22 23 constructor(owner: unknown, args: ArgsFor<NeatSignature>) { 24 super(owner, args); 25 registerDestructor(this, cleanup); 26 } 27 28 modify( 29 element: Element, 30 [lengthOfInput]: PositionalArgs<NeatSignature>, 31 { multiplier }: NamedArgs<NeatSignature> 32 ) { 33 assert( 34 `positional arg must be 'string' but was ${typeof lengthOfInput}`, 35 typeof lengthOfInput === 'string' 36 ); 37 38 assert( 39 `'multiplier' arg must be a number but was ${typeof multiplier}`, 40 multiplier ? typeof multiplier === "number" : true 41 ); 42 43 multiplier = modifier ?? 1000; 44 45 let updateTime = multiplier * lengthOfInput; 46 this.interval = setInterval(() => { 47 element.innerText = 48 `Behold, a type safe modifier moved after ${updateTime / 1000}s`; 49 }, updateTime) 50 } 51}
See the Contributing guide for details.
This project is licensed under the MIT License.
As with autotracking in general, “changes” here actually means that the tracked property was set—even if it was set to the same value. This is because autotracking does not cache the values of properties, only the last time they changed. See this blog post for a deep dive on how it works! ↩
No vulnerabilities found.
Reason
no binaries found in the repo
Reason
24 commit(s) and 1 issue activity found in the last 90 days -- score normalized to 10
Reason
no dangerous workflow patterns detected
Reason
license file detected
Details
Reason
detected GitHub workflow tokens with excessive permissions
Details
Reason
dependency not pinned by hash detected -- score normalized to 0
Details
Reason
no effort to earn an OpenSSF best practices badge detected
Reason
security policy file not detected
Details
Reason
project is not fuzzed
Details
Reason
SAST tool is not run on all commits -- score normalized to 0
Details
Reason
13 existing vulnerabilities detected
Details
Score
Last Scanned on 2024-11-25
The Open Source Security Foundation is a cross-industry collaboration to improve the security of open source software (OSS). The Scorecard provides security health metrics for open source projects.
Learn More