Mod Creation/Reusable Components with PetiteVue: Difference between revisions

From Melvor Idle
(User:Coolrox95 Add mod guide navigation)
m (Remove extra paren to fix syntax of line 16.)
Tag: visualeditor
 
(4 intermediate revisions by 2 users not shown)
Line 21: Line 21:
'''Example'''
'''Example'''


  <nowiki><!-- templates.html -->
  <syntaxhighlight lang="html" line><!-- templates.html -->
<template id="counter-component">
<template id="counter-component">
   <span class="text-light">{{ count }}</span>
   <span class="text-light">{{ count }}</span>
   <button class="btn btn-secondary" @click="inc">+</button>
   <button class="btn btn-secondary" @click="inc">+</button>
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>// manifest.json
  <syntaxhighlight lang="js" line>// manifest.json
{
{
   "load": ["templates.html"]
   "load": ["templates.html"]
}</nowiki>
}</syntaxhighlight>


  <nowiki>// setup.mjs
<small>''Comments in JSON are purely illustrative and not valid markup''</small>
 
  <syntaxhighlight lang="js" line>// setup.mjs
function Counter(props) {
function Counter(props) {
   return {
   return {
Line 48: Line 50:
     ui.create(Counter({ count: 0 }), document.getElementById('woodcutting-container'));
     ui.create(Counter({ count: 0 }), document.getElementById('woodcutting-container'));
   });
   });
}</nowiki>
}</syntaxhighlight>


=== ui.createStore(props: Record<string, unknown>): ComponentStore ===
=== ui.createStore(props: Record<string, unknown>): ComponentStore ===
Line 66: Line 68:
In the above example for <code>ui.create</code>, if you created a second <code>Counter</code> component, it would contain its own state and clicking the incrementing button on one would have no effect on the other. By using a store, you can share state in the following way:
In the above example for <code>ui.create</code>, if you created a second <code>Counter</code> component, it would contain its own state and clicking the incrementing button on one would have no effect on the other. By using a store, you can share state in the following way:


  <nowiki><!-- templates.html -->
  <syntaxhighlight lang="html" line><!-- templates.html -->
<template id="counter-component-using-store">
<template id="counter-component-using-store">
   <span class="text-light">{{ store.count }}</span>
   <span class="text-light">{{ store.count }}</span>
   <button class="btn btn-secondary" @click="store.inc">+</button>
   <button class="btn btn-secondary" @click="store.inc">+</button>
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>// manifest.json
  <syntaxhighlight lang="js" line>// manifest.json
{
{
   "load": ["templates.html"]
   "load": ["templates.html"]
}</nowiki>
}</syntaxhighlight>


  <nowiki>// setup.mjs
<small>''Comments in JSON are purely illustrative and not valid markup''</small>
 
  <syntaxhighlight lang="js" line>// setup.mjs
function CounterUsingStore({ store }) {
function CounterUsingStore({ store }) {
   return {
   return {
Line 99: Line 103:
     ui.create(CounterUsingStore({ store }), document.getElementById('firemaking-container'));
     ui.create(CounterUsingStore({ store }), document.getElementById('firemaking-container'));
   });
   });
}</nowiki>
}</syntaxhighlight>


Now in this example, both the counter on the Woodcutting page and the Firemaking page should stay in sync with the current count.
Now in this example, both the counter on the Woodcutting page and the Firemaking page should stay in sync with the current count.
Line 109: Line 113:
'''Parameters'''
'''Parameters'''


<code>template: string</code> The selector string for the template you want to clone. For example, to target <code><template id="static-component"><!-- --></template></code>, you would use <code>'#static-component'</code>.
<code>template: string</code> The selector string for the template you want to clone. For example, to target <syntaxhighlight lang="html" inline><template id="static-component"><!-- --></template></syntaxhighlight>, you would use <syntaxhighlight lang="html" inline>'#static-component'</syntaxhighlight>.


<code>host: HTMLElement</code> The element that the component should be appended to.
<code>host: HTMLElement</code> The element that the component should be appended to.
Line 119: Line 123:
'''Example'''
'''Example'''


  <nowiki><!-- static-templates.html -->
  <syntaxhighlight lang="html" line><!-- static-templates.html -->
<template id="my-static-component">
<template id="my-static-component">
   <h3>Hello, this is static HTML</h3>
   <h3>Hello, this is static HTML</h3>
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>// manifest.json
  <syntaxhighlight lang="js" line>// manifest.json
{
{
   "load": ["static-templates.html"]
   "load": ["static-templates.html"]
}</nowiki>
}</syntaxhighlight>
 
<small>''Comments in JSON are purely illustrative and not valid markup''</small>


  <nowiki>// setup.mjs
  <syntaxhighlight lang="js" line>// setup.mjs
export function setup({ onInterfaceReady }) {
export function setup({ onInterfaceReady }) {
   onInterfaceReady(() => {
   onInterfaceReady(() => {
Line 135: Line 141:
     ui.createStatic('#my-static-component', document.getElementById('woodcutting-container'));
     ui.createStatic('#my-static-component', document.getElementById('woodcutting-container'));
   });
   });
}</nowiki>
}</syntaxhighlight>


==== Nesting Static Components ====
==== Nesting Static Components ====
Line 143: Line 149:
For example, given the following templates:
For example, given the following templates:


  <nowiki><!-- static-templates.html -->
  <syntaxhighlight lang="html" line><!-- static-templates.html -->
<template id="static-parent">
<template id="static-parent">
   <h3>Hello, this is static HTML from the parent</h3>
   <h3>Hello, this is static HTML from the parent</h3>
Line 151: Line 157:
<template id="static-child">
<template id="static-child">
   <p>And this HTML is from a static child.</p>
   <p>And this HTML is from a static child.</p>
</template></nowiki>
</template></syntaxhighlight>


You could create the parent component using the following:
You could create the parent component using the following:


  <nowiki>// setup.mjs
  <syntaxhighlight lang="js" line>// setup.mjs
export function setup({ onInterfaceReady }) {
export function setup({ onInterfaceReady }) {
   onInterfaceReady(() => {
   onInterfaceReady(() => {
     ui.createStatic('#static-parent', document.getElementById('woodcutting-container'));
     ui.createStatic('#static-parent', document.getElementById('woodcutting-container'));
   });
   });
}</nowiki>
}</syntaxhighlight>


Which results in the following HTML being appended to the bottom of the Woodcutting page:
Which results in the following HTML being appended to the bottom of the Woodcutting page:


  <nowiki><h3>Hello, this is static HTML from the parent</h3>
  <syntaxhighlight lang="html" line><h3>Hello, this is static HTML from the parent</h3>
<div>
<div>
   <p>And this HTML is from a static child.</p>
   <p>And this HTML is from a static child.</p>
</div></nowiki>
</div></syntaxhighlight>


== Useful Patterns ==
== Useful Patterns ==
Line 177: Line 183:
Consider the following templates:
Consider the following templates:


  <nowiki><!-- templates.html -->
  <syntaxhighlight lang="html" line><!-- templates.html -->
<template id="block-component">
<template id="block-component">
   <div class="block">
   <div class="block">
Line 191: Line 197:
<template id="block-content">
<template id="block-content">
   <p v-for="line in lines">{{ line }}</p>
   <p v-for="line in lines">{{ line }}</p>
</template></nowiki>
</template></syntaxhighlight>


And defined components:
And defined components:


  <nowiki>
  <syntaxhighlight lang="js" line>
function Block(props) {
function Block(props) {
   return {
   return {
Line 218: Line 224:
     lines: props.lines
     lines: props.lines
   };
   };
}</nowiki>
}</syntaxhighlight>


A complete block component can be created with the following:
A complete block component can be created with the following:


  <nowiki>ui.create(Block({
  <syntaxhighlight lang="js" line>ui.create(Block({
   header: { title: 'My Block Component' },
   header: { title: 'My Block Component' },
   content: { lines: ['My first paragraph.', 'My second paragraph.'] }
   content: { lines: ['My first paragraph.', 'My second paragraph.'] }
}), document.getElementById('woodcutting-container'));</nowiki>
}), document.getElementById('woodcutting-container'));</syntaxhighlight>


=== Programmatically Manipulating Components ===
=== Programmatically Manipulating Components ===
Line 233: Line 239:
For example, using our <code>Counter</code> from above:
For example, using our <code>Counter</code> from above:


  <nowiki><!-- templates.html -->
  <syntaxhighlight lang="html" line><!-- templates.html -->
<template id="counter-component">
<template id="counter-component">
   <span class="text-light">{{ count }}</span>
   <span class="text-light">{{ count }}</span>
   <button class="btn btn-secondary" @click="inc">+</button>
   <button class="btn btn-secondary" @click="inc">+</button>
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>// setup.mjs
  <syntaxhighlight lang="js" line="1">// setup.mjs
function Counter(props) {
function Counter(props) {
   return {
   return {
Line 254: Line 260:
     // Save a reference here
     // Save a reference here
     const counter = Counter({ count: 0 });
     const counter = Counter({ count: 0 });
     ui.create(counter), document.getElementById('woodcutting-container'));
     ui.create(counter, document.getElementById('woodcutting-container'));


     // Manipulate here to reflect changes in the UI
     // Manipulate here to reflect changes in the UI
Line 261: Line 267:
     counter.inc();
     counter.inc();
   });
   });
}</nowiki>
}</syntaxhighlight>


== PetiteVue Quick Reference ==
== PetiteVue Quick Reference ==
Line 273: Line 279:
'''Example'''
'''Example'''


  <nowiki><template id="binding-example"><h1>{{ text }}</h1></template></nowiki>
  <syntaxhighlight lang="html" line><template id="binding-example"><h1>{{ text }}</h1></template></syntaxhighlight>


  <nowiki>function BindingExample(props) {
  <syntaxhighlight lang="js" line>function BindingExample(props) {
   return {
   return {
     $template: '#binding-example',
     $template: '#binding-example',
     text: props.text
     text: props.text
   };
   };
}</nowiki>
}</syntaxhighlight>


  <nowiki>ui.create(BindingExample({ text: 'Hello, Melvor!' }), host);
  <syntaxhighlight lang="js" line>ui.create(BindingExample({ text: 'Hello, Melvor!' }), host);
// -> <h1>Hello, Melvor!</h1></nowiki>
// -> <h1>Hello, Melvor!</h1></syntaxhighlight>


=== Attribute Binding ===
=== Attribute Binding ===
Line 291: Line 297:
'''Example'''
'''Example'''


  <nowiki><template id="attr-binding-example">
  <syntaxhighlight lang="html" line><template id="attr-binding-example">
   <span v-bind:class="`text-${(warning ? 'warning' : 'info')}`">
   <span v-bind:class="`text-${(warning ? 'warning' : 'info')}`">
     This message could be a warning or informational.
     This message could be a warning or informational.
   </span>
   </span>
</template></nowiki>
</template></syntaxhighlight>


This notation accomplishes the same:
This notation accomplishes the same:


  <nowiki><template id="attr-binding-example">
  <syntaxhighlight lang="html" line><template id="attr-binding-example">
   <span :class="`text-${(warning ? 'warning' : 'info')}`">
   <span :class="`text-${(warning ? 'warning' : 'info')}`">
     This message could be a warning or informational.
     This message could be a warning or informational.
   </span>
   </span>
</template></nowiki>
</template></syntaxhighlight>


=== Event Binding/Handling ===
=== Event Binding/Handling ===
Line 311: Line 317:
'''Example'''
'''Example'''


  <nowiki><template id="event-binding-example">
  <syntaxhighlight lang="html" line><template id="event-binding-example">
   <button v-on:click="onClick">Click Me!</button>
   <button v-on:click="onClick">Click Me!</button>
</template></nowiki>
</template></syntaxhighlight>


This notation accomplishes the same:
This notation accomplishes the same:


  <nowiki><template id="event-binding-example">
  <syntaxhighlight lang="html" line><template id="event-binding-example">
   <button @click="onClick">Click Me!</button>
   <button @click="onClick">Click Me!</button>
</template></nowiki>
</template></syntaxhighlight>


And would be used in the component like:
And would be used in the component like:


  <nowiki>function EventBindingExample() {
  <syntaxhighlight lang="js" line>function EventBindingExample() {
   return {
   return {
     $template: '#event-binding-template',
     $template: '#event-binding-template',
Line 330: Line 336:
     }
     }
   };
   };
}</nowiki>
}</syntaxhighlight>


=== Input Value Binding ===
=== Input Value Binding ===
Line 338: Line 344:
'''Example'''
'''Example'''


  <nowiki><template id="input-binding-example">
  <syntaxhighlight lang="html" line><template id="input-binding-example">
   <input v-model="value" />
   <input v-model="value" />
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>function InputBindingExample(props) {
  <syntaxhighlight lang="js" line>function InputBindingExample(props) {
   return {
   return {
     value: props.initialValue,
     value: props.initialValue,
Line 358: Line 364:


// Assume the player changes the input in the UI to "new value"
// Assume the player changes the input in the UI to "new value"
console.log(input.value); // -> "new value"</nowiki>
console.log(input.value); // -> "new value"</syntaxhighlight>


=== Conditional Rendering ===
=== Conditional Rendering ===
Line 366: Line 372:
'''Example'''
'''Example'''


  <nowiki><template id="conditional-example">
  <syntaxhighlight lang="html" line><template id="conditional-example">
   <span v-if="value % 15 === 0">FizzBuzz</span>
   <span v-if="value % 15 === 0">FizzBuzz</span>
   <span v-else-if="value % 3 === 0">Fizz</span>
   <span v-else-if="value % 3 === 0">Fizz</span>
   <span v-else-if="value % 5 === 0">Buzz</span>
   <span v-else-if="value % 5 === 0">Buzz</span>
   <span v-else>{{ value }}</span>
   <span v-else>{{ value }}</span>
</template></nowiki>
</template></syntaxhighlight>


  <nowiki>function ConditionalExample(props) {
  <syntaxhighlight lang="js" line>function ConditionalExample(props) {
   return {
   return {
     $template: 'conditional-example',
     $template: 'conditional-example',
Line 381: Line 387:


ui.create(ConditionalExample({ value: 6 }), host);
ui.create(ConditionalExample({ value: 6 }), host);
// -> <span>Fizz</span></nowiki>
// -> <span>Fizz</span></syntaxhighlight>
{{ModGuideNav}}
{{ModGuideNav}}
{{Menu}}

Latest revision as of 20:17, 1 November 2024

Melvor Idle ships with PetiteVue for mods to use to create reusable HTML components. The documentation from the official GitHub page in addition to the full Vue.js documentation (for clarity on definitions and what the PetiteVue directives do - there are many full Vue.js features that are unavailable) may assist in using the PetiteVue library. However, there are also helper functions for making it easier for mods to interact with PetiteVue.

Helper Functions

These are the functions provided by Melvor Idle to interact with PetiteVue. For the sake of avoiding edge cases and oddities surrounding how mods are loaded, you should use these instead of interacting with the PetiteVue global object directly.

ui.create(props: ComponentProps, host: HTMLElement): HTMLElement

Creates an instance of a component and mounts it within the HTML.

Parameters

props: ComponentProps The PetiteVue component function that you want to instantiate.

host: HTMLElement The element that the component should be appended to.

Returns

HTMLElement The host element.

Example

<!-- templates.html -->
<template id="counter-component">
  <span class="text-light">{{ count }}</span>
  <button class="btn btn-secondary" @click="inc">+</button>
</template>
// manifest.json
{
  "load": ["templates.html"]
}

Comments in JSON are purely illustrative and not valid markup

// setup.mjs
function Counter(props) {
  return {
    $template: '#counter-component',
    count: props.count,
    inc() {
      this.count++;
    }
  };
}

export function setup({ onInterfaceReady }) {
  onInterfaceReady(() => {
    // Create and append a Counter component to the bottom of the Woodcutting page
    ui.create(Counter({ count: 0 }), document.getElementById('woodcutting-container'));
  });
}

ui.createStore(props: Record<string, unknown>): ComponentStore

Creates a PetiteVue store for sharing state amongst components.

Parameters

props: Record<string, unknown> The props that the store should contain.

Returns

ComponentStore The PetiteVue store that can be shared between components.

Example

In the above example for ui.create, if you created a second Counter component, it would contain its own state and clicking the incrementing button on one would have no effect on the other. By using a store, you can share state in the following way:

<!-- templates.html -->
<template id="counter-component-using-store">
  <span class="text-light">{{ store.count }}</span>
  <button class="btn btn-secondary" @click="store.inc">+</button>
</template>
// manifest.json
{
  "load": ["templates.html"]
}

Comments in JSON are purely illustrative and not valid markup

// setup.mjs
function CounterUsingStore({ store }) {
  return {
    $template: '#counter-component-using-store',
    store
  };
}

export function setup({ onInterfaceReady }) {
  onInterfaceReady(() => {
    const store = ui.createStore({
      count: 0,
      inc() {
        this.count++;
      }
    });

    // Create and append a CounterUsingStore component to the bottom of the Woodcutting page
    ui.create(CounterUsingStore({ store }), document.getElementById('woodcutting-container'));
    // Create and append another CounterUsingStore component to the bottom of the Firemaking page
    ui.create(CounterUsingStore({ store }), document.getElementById('firemaking-container'));
  });
}

Now in this example, both the counter on the Woodcutting page and the Firemaking page should stay in sync with the current count.

ui.createStatic(template: string, host: HTMLElement): HTMLElement

Creates an instance of a static component (no PetiteVue bindings) and mounts it within the HTML. This helper function doesn't use PetiteVue but should be preferred if you only need to create a reusable static piece of HTML.

Parameters

template: string The selector string for the template you want to clone. For example, to target <template id="static-component"><!-- --></template>, you would use '#static-component'.

host: HTMLElement The element that the component should be appended to.

Returns

HTMLElement The host element.

Example

<!-- static-templates.html -->
<template id="my-static-component">
  <h3>Hello, this is static HTML</h3>
</template>
// manifest.json
{
  "load": ["static-templates.html"]
}

Comments in JSON are purely illustrative and not valid markup

// setup.mjs
export function setup({ onInterfaceReady }) {
  onInterfaceReady(() => {
    // Create the static component and place it at the bottom of the Woodcutting page
    ui.createStatic('#my-static-component', document.getElementById('woodcutting-container'));
  });
}

Nesting Static Components

In order to nest static components, child component templates need to be referenced by using a s-template attribute on the host element.

For example, given the following templates:

<!-- static-templates.html -->
<template id="static-parent">
  <h3>Hello, this is static HTML from the parent</h3>
  <div s-template="#static-child"></div>
</template>

<template id="static-child">
  <p>And this HTML is from a static child.</p>
</template>

You could create the parent component using the following:

// setup.mjs
export function setup({ onInterfaceReady }) {
  onInterfaceReady(() => {
    ui.createStatic('#static-parent', document.getElementById('woodcutting-container'));
  });
}

Which results in the following HTML being appended to the bottom of the Woodcutting page:

<h3>Hello, this is static HTML from the parent</h3>
<div>
  <p>And this HTML is from a static child.</p>
</div>

Useful Patterns

Nesting Components

PetiteVue components may be nested to create larger reusable components. This pattern, likely combined with a PetiteVue store, can be followed all the way to creating the entire UI for your mod in a single parent component (which would be preferred, rather than calling ui.create many times).

Consider the following templates:

<!-- templates.html -->
<template id="block-component">
  <div class="block">
    <div class="block-header" v-scope="BlockHeader(headerProps)"></div>
    <div class="block-content" v-scope="BlockContent(contentProps)"></div>
  </div>
</template>

<template id="block-header">
  <h3 class="block-title">{{ title }}</h3>
</template>

<template id="block-content">
  <p v-for="line in lines">{{ line }}</p>
</template>

And defined components:

function Block(props) {
  return {
    $template: '#block-component',
    BlockHeader,
    BlockContent,
    headerProps: props.header,
    contentProps: props.content
  };
}

function BlockHeader(props) {
  return {
    $template: '#block-header',
    title: props.title
  };
}

function BlockContent(props) {
  return {
    $template: '#block-content',
    lines: props.lines
  };
}

A complete block component can be created with the following:

ui.create(Block({
  header: { title: 'My Block Component' },
  content: { lines: ['My first paragraph.', 'My second paragraph.'] }
}), document.getElementById('woodcutting-container'));

Programmatically Manipulating Components

If you need to programmatically manipulate a component's (or store's) state, save the reference to the props object being passed into ui.create. The state should only be manipulated through methods on the object, not directly setting properties.

For example, using our Counter from above:

<!-- templates.html -->
<template id="counter-component">
  <span class="text-light">{{ count }}</span>
  <button class="btn btn-secondary" @click="inc">+</button>
</template>
// setup.mjs
function Counter(props) {
  return {
    $template: '#counter-component',
    count: props.count,
    inc() {
      this.count++;
    }
  };
}

export function setup({ onInterfaceReady }) {
  onInterfaceReady(() => {
    // Save a reference here
    const counter = Counter({ count: 0 });
    ui.create(counter, document.getElementById('woodcutting-container'));

    // Manipulate here to reflect changes in the UI
    // BAD: counter.count++;
    // GOOD:
    counter.inc();
  });
}

PetiteVue Quick Reference

This is not an exhaustive rundown of PetiteVue features, but these are likely the most common to be used and examples of each.

Text Bindings

Render text within HTML using the double-curly braces notation {{ }}.

Example

<template id="binding-example"><h1>{{ text }}</h1></template>
function BindingExample(props) {
  return {
    $template: '#binding-example',
    text: props.text
  };
}
ui.create(BindingExample({ text: 'Hello, Melvor!' }), host);
// -> <h1>Hello, Melvor!</h1>

Attribute Binding

Bind an attribute to props using v-bind directive, or : for short.

Example

<template id="attr-binding-example">
  <span v-bind:class="`text-${(warning ? 'warning' : 'info')}`">
    This message could be a warning or informational.
  </span>
</template>

This notation accomplishes the same:

<template id="attr-binding-example">
  <span :class="`text-${(warning ? 'warning' : 'info')}`">
    This message could be a warning or informational.
  </span>
</template>

Event Binding/Handling

Bind event handlers using the v-on directive, or @ for short.

Example

<template id="event-binding-example">
  <button v-on:click="onClick">Click Me!</button>
</template>

This notation accomplishes the same:

<template id="event-binding-example">
  <button @click="onClick">Click Me!</button>
</template>

And would be used in the component like:

function EventBindingExample() {
  return {
    $template: '#event-binding-template',
    onClick() {
      alert('You clicked me!');
    }
  };
}

Input Value Binding

Input values can be bound using the v-model directive.

Example

<template id="input-binding-example">
  <input v-model="value" />
</template>
function InputBindingExample(props) {
  return {
    value: props.initialValue,
    setValue(val) {
      this.input = val;
    }
  };
}

const input = InputBindingExample({ initialValue: 'this is the initial value' });
ui.create(input, host);
// -> <input value="this is the initial value" />
input.setValue('now this value');
// -> <input value="now this value" />

// Assume the player changes the input in the UI to "new value"
console.log(input.value); // -> "new value"

Conditional Rendering

You can conditionally render elements using the v-if, v-else, and v-else-if directives.

Example

<template id="conditional-example">
  <span v-if="value % 15 === 0">FizzBuzz</span>
  <span v-else-if="value % 3 === 0">Fizz</span>
  <span v-else-if="value % 5 === 0">Buzz</span>
  <span v-else>{{ value }}</span>
</template>
function ConditionalExample(props) {
  return {
    $template: 'conditional-example',
    value: props.value
  };
}

ui.create(ConditionalExample({ value: 6 }), host);
// -> <span>Fizz</span>