Skip to main content
Version: 0.2.x

Component basics

Components are the building blocks for custom functionality in yuor theme. Each component is defined in src/components/, by a class which use the @Component() decorator and which extends ThemeComponent.

Each component may also optionally include a template and styles.

src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
export class MyComponent extends ThemeComponent {
}
src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
export class MyComponent extends ThemeComponent {
}

This example component could be used anywhere in a theme as <my-component>.

Decorators

The @Component decorator is attached to the class declaration following it. Most decorators provided by SALVO-TS also accept optional parameters if you call them like a function (i.e. @Component({ ... })).

Component naming

Notice how the component name is formed of two words - in Typescript it's written in PascalCase, and in HTML its referred to using an element with the same name in kebab-case. These conventions are how the framework identifies and loads your component automatically.

Props

Props are essentially the inputs which your component can accept. For example, a "quantity spinner" component may have min, max, and currentValue props.

Props are defined on the component class and can be passed on the component element:

src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
class QuantitySpinner extends ThemeComponent {
@Prop currentValue!: number;
// …
}
src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
class QuantitySpinner extends ThemeComponent {
@Prop currentValue!: number;
// …
}
src/components/MyComponent.liquid
html
<quantity-spinner
current-value="1"
>
</quantity-spinner>
src/components/MyComponent.liquid
html
<quantity-spinner
current-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
@Prop propName!: string;
 
// You can override the default attribute name
@Prop({ attr: 'value' }) currentValue!: string;
 
// Props are required by default, unless you specify a default.
@Prop({ default: 'user' }) customerName!: string;
@Prop({ default: null }) customerEmail!: string|null;
 
// Props can have (almost) any type
@Prop name!: string;
@Prop count!: number;
@Prop countries!: string[];
@Prop customer!: Customer;
}
typescript
@Component
export default class MyComponent extends ThemeComponent {
// Basic definition
@Prop propName!: string;
 
// You can override the default attribute name
@Prop({ attr: 'value' }) currentValue!: string;
 
// Props are required by default, unless you specify a default.
@Prop({ default: 'user' }) customerName!: string;
@Prop({ default: null }) customerEmail!: string|null;
 
// Props can have (almost) any type
@Prop name!: string;
@Prop count!: number;
@Prop countries!: string[];
@Prop customer!: Customer;
}
Non-null assertion in prop definitions

Props are defined with the non-null assertion operator (!), because they're automatically populated by the framework, and therefore won't be initialized in the constructor normally.

Passing props

Props can be passed in on component elements as HTML attributes.

html
<my-component
prop-name="test"
customer-name="{{ customer.name|escape }}"
countries="[&quot;GB&quot;, &quot;FR&quot;, &quot;DE&quot;]"
customer="{{ customer|json|escape }}">
</my-component>
html
<my-component
prop-name="test"
customer-name="{{ customer.name|escape }}"
countries="[&quot;GB&quot;, &quot;FR&quot;, &quot;DE&quot;]"
customer="{{ customer|json|escape }}">
</my-component>

The attribute name for a given prop will by default be the same name converted to kebab-case, unless you specify otherwise in the prop arguments:

Prop definitionAttribute name
ts
@Prop example!: string;
ts
@Prop example!: string;
html
example=""
html
example=""
ts
@Prop loremIpsum!: string;
ts
@Prop loremIpsum!: string;
html
lorem-ipsum=""
html
lorem-ipsum=""
ts
@Prop({ attr: 'variant' }) selectedVariant!: string;
ts
@Prop({ attr: 'variant' }) selectedVariant!: string;
html
variant=""
html
variant=""

Simple props like strings can be passed directly in the attribute value, whereas complex props like objects need to be JSON-encoded:

Prop definitionAttribute value
ts
@Prop example!: string;
ts
@Prop example!: string;
html
example="Lorem ipsum"
html
example="Lorem ipsum"
ts
@Prop example!: number;
ts
@Prop example!: number;
html
example="42"
html
example="42"
ts
@Prop example!: boolean;
ts
@Prop example!: boolean;
html
example="true"
html
example="true"

Complex prop values need to be JSON-encoded first - for objects which already exist, you can usually pass them in directly using Liquid's |json and |escape filters:

ts
@Prop product!: Shopify.Liquid.Product;
ts
@Prop product!: Shopify.Liquid.Product;
html
<my-component
product="{{ product|json|escape }}"
>
</my-component>
html
<my-component
product="{{ product|json|escape }}"
>
</my-component>

For custom objects, you will need to construct the JSON first - the easiest way to do this often using {% capture %}:

ts
@Prop example!: {
key: string;
value: number;
};
ts
@Prop example!: {
key: string;
value: number;
};
html
{% capture value %}
{
"key": "abc",
"value": 42
}
{% endcapture %}
<my-component
example="{{ value|escape }}"
>
</my-component>
html
{% capture value %}
{
"key": "abc",
"value": 42
}
{% endcapture %}
<my-component
example="{{ value|escape }}"
>
</my-component>
Attribute escaping

You must always ensure that prop values are properly escaped. This means using the |json filter when constructing JSON objects, and using the |escape filter when passing unsafe values to attributes.

Custom classes as props

SALVO-TS also supports using custom classes for props. This allows you to pass JSON into a component, and then access the prop as a properly-initialized class instance.

Classes used in this way should implement SalvoSerializable so they have control over how they are parsed. Otherwise, the framework will open populate basic attribute (essentially running Object.assign(new CustomClass(), JSON.parse(attributeValue))).

typescript
import { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';
 
// Data which will be passed to the prop
interface JsonData {
foo: string;
bar: 'enabled'|'disabled';
}
 
// The type that we want the prop to instantiate to
@staticImplements<SalvoSerializable<JsonData>>()
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: JsonData): 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(): JsonData {
return {
foo: this.foo,
bar: this.isBar ? 'enabled' : 'disabled'
};
}
}
typescript
import { SalvoSerializable, staticImplements } from '@eastsideco/salvo-ts';
 
// Data which will be passed to the prop
interface JsonData {
foo: string;
bar: 'enabled'|'disabled';
}
 
// The type that we want the prop to instantiate to
@staticImplements<SalvoSerializable<JsonData>>()
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: JsonData): 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(): JsonData {
return {
foo: this.foo,
bar: this.isBar ? 'enabled' : 'disabled'
};
}
}
typescript
@Component
export class MyComponent extends ThemeComponent {
@Prop example!: MyComplexObject;
}
typescript
@Component
export class MyComponent extends ThemeComponent {
@Prop example!: MyComplexObject;
}
html
{% capture value %}
{
"foo": "lorem ipsum",
"bar": "enabled"
}
{% endcapture %}
<my-component
example="{{ value|escape }}"
>
</my-component>
html
{% capture value %}
{
"foo": "lorem ipsum",
"bar": "enabled"
}
{% endcapture %}
<my-component
example="{{ value|escape }}"
>
</my-component>

Attribute reflection

Changes to props can be automatically reflected back to the prop attribute using attribute reflection.

If attribute reflection is enabled then:

  • When the prop changes, the associated attribute will be updated with the new value.
  • When the attribute changes, the value will be re-parsed and updated on the prop.

Simple props (i.e. number, string, boolean) are reflected by default. Complex props (i.e. objects) are not reflected by default.

These defaults can by overridden using the reflectAttr argument in the prop decorator:

typescript
// Don't reflect this simple prop
@Prop({ reflectAttr: false }) score!: number;
// Do reflect this complex prop
@Prop({ reflectAttr: true }) productData!: Shopify.Liquid.Product;
typescript
// Don't reflect this simple prop
@Prop({ reflectAttr: false }) score!: number;
// Do reflect this complex prop
@Prop({ reflectAttr: true }) productData!: Shopify.Liquid.Product;

Refs

Refs allow you to store references to child elements which appear inside of your component.

Refs are defined on the component class and identified by [data-ref] attributes on child elements.

src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
export default class MyComponent extends ThemeComponent {
@Ref greeting!: HTMLElement;
}
src/components/MyComponent.ts
typescript
import { Component, Prop, Ref } from '@eastsideco/salvo-ts';
import { ThemeComponent } from '@/theme/ThemeComponent';
 
@Component
export default class MyComponent extends ThemeComponent {
@Ref greeting!: HTMLElement;
}
src/components/MyComponent.liquid
html
<my-component>
<span data-ref="greeting">
Hello there!
</span>
</my-component>
src/components/MyComponent.liquid
html
<my-component>
<span data-ref="greeting">
Hello there!
</span>
</my-component>

Defining refs

Refs are defined as part of the component class as normal class properties, marked up with the @Ref decorator:

typescript
@Component
export default class MyComponent extends ThemeComponent {
// Basic definition
@Ref nameSpan!: HTMLElement;
 
// You can specify the specific type of element to benefit from better type checking
@Ref nameInput!: HTMLInputElement;
 
// (deprecated) You can override the selector
@Ref({ selector: '[data-ref="foo"]' }) foobar!: HTMLElement;
}
typescript
@Component
export default class MyComponent extends ThemeComponent {
// Basic definition
@Ref nameSpan!: HTMLElement;
 
// You can specify the specific type of element to benefit from better type checking
@Ref nameInput!: HTMLInputElement;
 
// (deprecated) You can override the selector
@Ref({ selector: '[data-ref="foo"]' }) foobar!: HTMLElement;
}

Passing refs

Refs are identified using [data-ref] attributes, added to the child element:

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>

Other components can also be used as refs, which allows composite components to be created:

html
<product-form>
<product-option-picker data-ref="options">
</product-option-picker>
<quantity-spinner data-ref="quantity">
</quantity-spinner>
</product-form>
html
<product-form>
<product-option-picker data-ref="options">
</product-option-picker>
<quantity-spinner data-ref="quantity">
</quantity-spinner>
</product-form>
src/components/ProductForm.ts
typescript
@Component
export class ProductForm extends ThemeComponent {
@Ref quantity!: QuantitySpinner;
@Ref options!: ProductOptionPicker;
 
async addToCart() {
const variant = this.options.selectedVariant; // Access child component props directly
const quantity = this.quantity.val
                                          
 
if (variant) {
await this.$cart.addItem({
id: variant.id,
quantity,
});
}
}
}
src/components/ProductForm.ts
typescript
@Component
export class ProductForm extends ThemeComponent {
@Ref quantity!: QuantitySpinner;
@Ref options!: ProductOptionPicker;
 
async addToCart() {
const variant = this.options.selectedVariant; // Access child component props directly
const quantity = this.quantity.val
                                          
 
if (variant) {
await this.$cart.addItem({
id: variant.id,
quantity,
});
}
}
}

Optional refs

By default refs are required, but optional refs can be defined by passing the required argument to the ref decorator:

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() {
// Using an optional ref without checking it for null will result in an error
this.$log.debug('review score', this.reviews.score);
Object is possibly 'null'.2531Object is possibly 'null'.
(property) ProductForm.reviews: ReviewStars | null
}
}
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() {
// Using an optional ref without checking it for null will result in an error
this.$log.debug('review score', this.reviews.score);
Object is possibly 'null'.2531Object is possibly 'null'.
(property) ProductForm.reviews: ReviewStars | null
}
}

Array refs

Array refs allow you to capture multiple instances of an element in a single ref. For example, all of the slides in slideshow component.

html
<product-option-buttons>
{%- for option in options -%}
<button data-ref="button">
{{ option.value }}
</button>
{%- endfor -%}
</product-option-buttons>
html
<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[];
}

Array refs are not inherently optional - if you want to allow zero matches, pass the required: false argument to the ref decorator.

Component decorator

The component decorator allows you to specify an alternative name for the component element, which may be useful when the generated name would be cumbersome:

ts
@Component
export class VATInput extends ThemeComponent {
}
ts
@Component
export class VATInput extends ThemeComponent {
}
html
<v-a-t-input></v-a-t-input>
html
<v-a-t-input></v-a-t-input>