Component basics
Creating components
Components are defined by classes which use the @Component() decorator and which extend BaseComponent. (See base component for more information about what functionality the base component includes.)
typescript// components/MyComponent.tsimport { Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';@Componentexport class MyComponent extends ThemeComponent {}
typescript// components/MyComponent.tsimport { Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';@Componentexport class MyComponent extends ThemeComponent {}
html// liquid<my-component></my-component>
html// liquid<my-component></my-component>
The @Component decorator registers the component with SALVO-TS. If you're not familiar with decorators in Typescript, the concept is similar to that found in other languages like Java or C#. The decorator is like a function which runs on the code it's attached to (in this case class MyComponent). It's not nessacry to understand how decorators in-depth to use SALVO-TS, but if you're interested you can learn more here.
The @Component, @Propand @Ref parameters take an optional setings parameter, but if you're not
passing it you don't need to include the parentheses (()) when using them - that is, they can be used as either @Component or @Component({ .. }).
It's important to name your components properly - see the component introduction for more details.
Props
Props are the input that your component accepts. For example, a "quantity spinner" component may have min, max, and currentValue props.
Props are passed via attributes on the component element, i.e.:
html<quantity-spinner min="1" max="10" value="1"></quantity-spinner>
html<quantity-spinner min="1" max="10" value="1"></quantity-spinner>
Defining props
Props are defined as part of the component class as normal class properties, marked up with the @Prop decorator:
typescript@Componentexport default class MyComponent extends ThemeComponent {// Basic definition - this prop will be passed using the attribute 'prop-name' by default@Prop propName!: string;// You can override the attribute name@Prop({ attr: 'foo' }) foobar!: string;// You can specify a default - if no default is specified and the prop doesn't exist,// it is treated as an error@Prop({ default: 'user' }) customerName!: string;// Props can have any type - they will be automatically parsed for you@Prop name!: string;@Prop count!: number;@Prop countries!: string[];@Prop customer!: Customer;}
typescript@Componentexport default class MyComponent extends ThemeComponent {// Basic definition - this prop will be passed using the attribute 'prop-name' by default@Prop propName!: string;// You can override the attribute name@Prop({ attr: 'foo' }) foobar!: string;// You can specify a default - if no default is specified and the prop doesn't exist,// it is treated as an error@Prop({ default: 'user' }) customerName!: string;// Props can have any type - they will be automatically parsed for you@Prop name!: string;@Prop count!: number;@Prop countries!: string[];@Prop customer!: Customer;}
! used in prop definitions?! is a typescript operator named the 'non-null assertion operator'. By defining foo!: string, it means you assert that foo will be set to some kind of string before you access it - that it will definitely be assigned at some point, even if Typescript can't see where that happens in your code.
This is required for props/refs because you never assign to these before using them. SALVO-TS handles initializing props/refs for you, but Typescript isn't aware of this process using from the information that exists in your class -- so you must tell Typescript explicitly, using !.
Passing props
Then you can use these in your liquid templates:
html<my-componentprop-name="test"foo="bar"customer-name="{{ customer.name|escape }}"name="example"count="102"countries="["GB", "FR", "DE"]"customer="{{ customer|json|escape }}"></my-component>
html<my-componentprop-name="test"foo="bar"customer-name="{{ customer.name|escape }}"name="example"count="102"countries="["GB", "FR", "DE"]"customer="{{ customer|json|escape }}"></my-component>
As you can see from the countries example above, passing in large object props can get quite complex, especially when it comes to ensuring that the attributes are properly escaped.
To get around this, you can pass larger props by creating an application/json script as a child of the component, with a [data-prop] attribute:
// TODO: would it make more sense to provide a single interface for passing all props via a single application/json script, rather than separate?
html<my-componentname="example"><script type="application/json" data-prop="countries">["GB","FR","DE"]</script></my-component>
html<my-componentname="example"><script type="application/json" data-prop="countries">["GB","FR","DE"]</script></my-component>
|json and/or |escape filters.Examples:
htmlPass a simple string or number:<my-component value="{{ value|escape }}"></my-component>Pass a JSON-able Liquid object (1):<my-component value="{{ value|json|escape }}"></my-component>Pass a JSON-able Liquid object (2):<my-component><script type="application/json" data-prop="value">{{ value|json }}</script></my-component>Pass a custom object:<my-component><script type="application/json" data-prop="value">{"foo": 123,"bar": {{ value|json }}}</script></my-component>
htmlPass a simple string or number:<my-component value="{{ value|escape }}"></my-component>Pass a JSON-able Liquid object (1):<my-component value="{{ value|json|escape }}"></my-component>Pass a JSON-able Liquid object (2):<my-component><script type="application/json" data-prop="value">{{ value|json }}</script></my-component>Pass a custom object:<my-component><script type="application/json" data-prop="value">{"foo": 123,"bar": {{ value|json }}}</script></my-component>
Using complex types as props
SALVO-TS supports using objects and arrays as props, but there are some limitations you need to be aware of.
You should only use basic interface types (with only fields, no functions) or types that implement SalvoSerializable as props.
When the framework populates a prop, it will create a new instance of that type and then call Object.assign with the object provided by Liquid. This means that more complex types may not be fully initialized if loaded via a prop.
If the type implements SalvoSerializable by having a fromSalvoPropValue() function, it's used instead to allow the object to decide how to handle the prop value.
typescript// Component@Componentclass MyComponent extends ThemeComponent {@Prop prop!: PropType;}// Liquid<my-component prop="{"foo": "bar"}">// Prop loading processif (PropType.fromSalvoPropValue) {component[prop] = PropType.fromSalvoPropValue(JSON.parse('{"foo": "bar"}'));} else {component[prop] = Object.assign(new PropType(), JSON.parse('{"foo": "bar"}'));}
typescript// Component@Componentclass MyComponent extends ThemeComponent {@Prop prop!: PropType;}// Liquid<my-component prop="{"foo": "bar"}">// Prop loading processif (PropType.fromSalvoPropValue) {component[prop] = PropType.fromSalvoPropValue(JSON.parse('{"foo": "bar"}'));} else {component[prop] = Object.assign(new PropType(), JSON.parse('{"foo": "bar"}'));}
If you're writing a class that is intended to be used as a prop type and you'd like more control over how it's loaded, you can implement the SalvoSerializable interface by defining a static fromSalvoPropValue function:
typescriptimport { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';// Data that will be passed to the propinterface MyComplexObjectPropData {foo: string;bar: 'enabled'|'disabled';}// The type that we want the prop to instantiate to@staticImplements<SalvoSerializable<FooPropData>>()class MyComplexObject {foo: string;isBar: boolean = false;constructor(foo: string) {this.foo = foo;}// Function which takes the data passed to the prop and returns and instance of this classstatic fromSalvoPropValue(value: MyComplexObjectPropData): MyComplexObject {let obj = new MyComplexObject(value.foo);obj.isBar = value.bar == 'enabled';return obj;}// Function which converts the current state back into the attribute valuetoSalvoPropValue(): MyComplexObjectPropData {return {foo: this.foo,bar: this.isBar ? 'enabled' : 'disabled'};}}// usage@Componentexport class MyComponent extends ThemeComponent {@Prop example!: MyComplexObject;}
typescriptimport { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';// Data that will be passed to the propinterface MyComplexObjectPropData {foo: string;bar: 'enabled'|'disabled';}// The type that we want the prop to instantiate to@staticImplements<SalvoSerializable<FooPropData>>()class MyComplexObject {foo: string;isBar: boolean = false;constructor(foo: string) {this.foo = foo;}// Function which takes the data passed to the prop and returns and instance of this classstatic fromSalvoPropValue(value: MyComplexObjectPropData): MyComplexObject {let obj = new MyComplexObject(value.foo);obj.isBar = value.bar == 'enabled';return obj;}// Function which converts the current state back into the attribute valuetoSalvoPropValue(): MyComplexObjectPropData {return {foo: this.foo,bar: this.isBar ? 'enabled' : 'disabled'};}}// usage@Componentexport class MyComponent extends ThemeComponent {@Prop example!: MyComplexObject;}
Attribute reflection
Changes to props can be automatically reflected back to the appropriate HTML attribute using 'attribute relection'.
If attribute reflection is enabled then:
- When the prop changes, the associated attribute will be updated to a string version of the new value.
- When the attribute changes, the value will be re-parsed and set on the prop.
Props with simple types (i.e. number, string, boolean) are reflected by default. Props with complex types (i.e. objects) are not reflected by default.
You can control attribute relection by passing the reflectAttr option in the prop decorator:
ts// Reflection is enabled for simple types by default@Prop score: number;// Don't reflect changes to this attribute@Prop({ reflectAttr: false }) score: number;// Reflection is disabled for complext types by default@Prop score: number;// Do reflect changes to this attribute@Prop({ reflectAttr: true }) productData: Shopify.Liquid.Product;
ts// Reflection is enabled for simple types by default@Prop score: number;// Don't reflect changes to this attribute@Prop({ reflectAttr: false }) score: number;// Reflection is disabled for complext types by default@Prop score: number;// Do reflect changes to this attribute@Prop({ reflectAttr: true }) productData: Shopify.Liquid.Product;
Refs
Refs work similarly to props, but allow you to grab references to elements that appear inside of your component.
Defining refs
typescriptimport { Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';@Componentexport default class MyComponent extends ThemeComponent {// Basic definition - this ref will refer to the element with the selector [data-ref="name-span"] by default@Ref nameSpan!: HTMLElement;// You can override the selector (discouraged)@Ref({ selector: '[data-ref="foo"]' }) foobar!: HTMLElement;// You can specify a specific type of element to benefit from better type checking@Ref nameInput!: HTMLInputElement;}
typescriptimport { Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';@Componentexport default class MyComponent extends ThemeComponent {// Basic definition - this ref will refer to the element with the selector [data-ref="name-span"] by default@Ref nameSpan!: HTMLElement;// You can override the selector (discouraged)@Ref({ selector: '[data-ref="foo"]' }) foobar!: HTMLElement;// You can specify a specific type of element to benefit from better type checking@Ref nameInput!: HTMLInputElement;}
Passing Refs
Then use mark the elements you want to refer to using data-ref="..." in your liquid templates:
html<my-component><div data-ref="foo"></div><p>What is your name?<input data-ref="nameInput"/></p><p>Hello, <span data-ref="nameSpan"></span></p></my-component>
html<my-component><div data-ref="foo"></div><p>What is your name?<input data-ref="nameInput"/></p><p>Hello, <span data-ref="nameSpan"></span></p></my-component>
You can use other components as refs, allowing you to create composite components. This allows you to create smaller components that are focused on a single task, and then compose them to achieve more complex functionality without creating huge components which are hard to reason about.
For example:
html// static/snippets/product_form.liquid<product-form>...<product-option-picker data-ref="options">...</product-option-picker><quantity-spinner data-ref="quantity">...</quantity-spinner></product-form>
html// static/snippets/product_form.liquid<product-form>...<product-option-picker data-ref="options">...</product-option-picker><quantity-spinner data-ref="quantity">...</quantity-spinner></product-form>
typescript// src/components/ProductForm.tsimport { Component, Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';import { QuantitySpinner } from '@/components/QuantitySpinner';import { ProductOptionPicker } from '@/components/ProductOptionPicker';@Componentexport class ProductForm extends ThemeComponent {@Ref quantity!: QuantitySpinner;@Ref options!: ProductOptionPicker;async addToCart() {const quantity = this.quantity.value;const variant = this.options.selectedVariant;if (variant) {await this.$cart.addItem({id: variant.id,quantity,});}}}
typescript// src/components/ProductForm.tsimport { Component, Component, Prop, Ref } from '@eastsideco/salvo-ts';import { ThemeComponent } from '@/theme/ThemeComponent';import { QuantitySpinner } from '@/components/QuantitySpinner';import { ProductOptionPicker } from '@/components/ProductOptionPicker';@Componentexport class ProductForm extends ThemeComponent {@Ref quantity!: QuantitySpinner;@Ref options!: ProductOptionPicker;async addToCart() {const quantity = this.quantity.value;const variant = this.options.selectedVariant;if (variant) {await this.$cart.addItem({id: variant.id,quantity,});}}}
Optional Refs
By default, refs are required. If the referenced element is not found, an error will be thrown.
If you want to ref an element that may or may not exist, you should pass required: false and make the ref nullable:
ts@Componentexport class ProductForm extends ThemeComponent {// This element only exists if the reviews setting is turned on@Ref({ required: false }) reviews!: ReviewStars|null;logReviewScore() {if (!this.reviews) { // TS will ensure you handle the case where reviews is nullreturn;}this.$log.debug('review score', this.reviews.score);}}
ts@Componentexport class ProductForm extends ThemeComponent {// This element only exists if the reviews setting is turned on@Ref({ required: false }) reviews!: ReviewStars|null;logReviewScore() {if (!this.reviews) { // TS will ensure you handle the case where reviews is nullreturn;}this.$log.debug('review score', this.reviews.score);}}
Array Refs
Sometimes you need to refer to elements which are output in a loop. In this case, it usually makes sense to refer to an array of elements.
To make an array ref, simply define the ref as an array type:
html// liquid<product-option-buttons>{%- for option in options -%}<button data-ref="button">{{ option.value }}</button>{%- endfor -%}</product-option-buttons>
html// liquid<product-option-buttons>{%- for option in options -%}<button data-ref="button">{{ option.value }}</button>{%- endfor -%}</product-option-buttons>
ts@Componentexport class ProductOptionButtons extends ThemeComponent {@Ref buttons!: HTMLButtonElement[];}
ts@Componentexport class ProductOptionButtons extends ThemeComponent {@Ref buttons!: HTMLButtonElement[];}
Just like other refs, array refs are required by default. This means there must be at least one matching element.
If you want to allow for an array ref with zero elements, pass required: false. You don't need to change the type - if there are zero matches then it'll simply return a zero-length array.