Skip to main content
Version: 0.1.x

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.ts
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
@Component
export class MyComponent extends ThemeComponent {
}
typescript
// components/MyComponent.ts
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
@Component
export 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.

Decorators with optional parameters

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({ .. }).

Component naming

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
@Component
export 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
@Component
export 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;
}
Details: Why is ! 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-component
prop-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-component
prop-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?

not implemented
html
<my-component
name="example">
<script type="application/json" data-prop="countries">
[
"GB",
"FR",
"DE"
]
</script>
</my-component>
html
<my-component
name="example">
<script type="application/json" data-prop="countries">
[
"GB",
"FR",
"DE"
]
</script>
</my-component>
You must always ensure that prop values are properly escaped. This usually means using the |json and/or |escape filters.

Examples:

html
Pass 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>
html
Pass 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.

Complex prop types

You should only use basic interface types (with only fields, no functions) or types that implement SalvoSerializable as props.

Details: Why these limitations?

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.

Details: Illustration of prop loading process
typescript
// Component
@Component
class MyComponent extends ThemeComponent {
@Prop prop!: PropType;
}
// Liquid
<my-component prop="{"foo": "bar"}">
// Prop loading process
if (PropType.fromSalvoPropValue) {
component[prop] = PropType.fromSalvoPropValue(JSON.parse('{"foo": "bar"}'));
} else {
component[prop] = Object.assign(new PropType(), JSON.parse('{"foo": "bar"}'));
}
typescript
// Component
@Component
class MyComponent extends ThemeComponent {
@Prop prop!: PropType;
}
// Liquid
<my-component prop="{"foo": "bar"}">
// Prop loading process
if (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:

Details: SalvoSerializable Example
typescript
import { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';
// Data that will be passed to the prop
interface 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 class
static 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 value
toSalvoPropValue(): MyComplexObjectPropData {
return {
foo: this.foo,
bar: this.isBar ? 'enabled' : 'disabled'
};
}
}
// usage
@Component
export class MyComponent extends ThemeComponent {
@Prop example!: MyComplexObject;
}
typescript
import { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';
// Data that will be passed to the prop
interface 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 class
static 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 value
toSalvoPropValue(): MyComplexObjectPropData {
return {
foo: this.foo,
bar: this.isBar ? 'enabled' : 'disabled'
};
}
}
// usage
@Component
export 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.

Enable or disbable prop attribute reflection.

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

typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
@Component
export 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;
}
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
@Component
export 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>
Using other components as refs

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.ts
import { Component, Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
import { QuantitySpinner } from '@/components/QuantitySpinner';
import { ProductOptionPicker } from '@/components/ProductOptionPicker';
@Component
export 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.ts
import { Component, Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
import { QuantitySpinner } from '@/components/QuantitySpinner';
import { ProductOptionPicker } from '@/components/ProductOptionPicker';
@Component
export 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
@Component
export 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 null
return;
}
this.$log.debug('review score', this.reviews.score);
}
}
ts
@Component
export 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 null
return;
}
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
@Component
export class ProductOptionButtons extends ThemeComponent {
@Ref buttons!: HTMLButtonElement[];
}
ts
@Component
export class ProductOptionButtons extends ThemeComponent {
@Ref buttons!: HTMLButtonElement[];
}
Optional array refs

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.