Nitro

Features

Third-party libraries

Select boxes, date pickers, rich editors and charts written for plain JavaScript work in a Nitro component. Write the library's binding once, as an attribute of your own, and Nitro starts it, keeps its value in step with a property, and tears it down, wherever the element comes and goes.

On this page

A library such as select2 or Flatpickr takes over an element: it hides it, builds markup of its own beside it, and reports changes in its own way. Three things make it work with a component that renders again:

  • The library owns its markup. Wrap it in nitro:ignore, so a render never touches what it built.
  • The value goes both ways. When the visitor chooses, the library sets the property; when anything else changes the property, the library shows it.
  • It is torn down. When its element leaves the page, the library's instance goes with it.

A nitro: attribute of your own does all three in one place. Nitro runs it when its element appears and cleans it up when the element goes, so the same binding works after a visit, in an @if, in a new row of a list, and after the back button, with no event to listen for.

Binding a library

  1. Name the attribute

    List it in config/nitro.php, so the compiler accepts it:

    PHP
    // config/nitro.php
    'attributes' => ['select2', 'flatpickr', 'tom-select', 'choices'],
    
  2. Write the binding

    Register a directive of the same name in a script your layout loads, with the library. It receives the element, the attribute's value (here, the property's name) and the component. The last two lines register it whenever the script runs: before Nitro starts, on a page's first load, or after, when a visit brings the layout that loads it:

    JavaScript
    // resources/js/libraries.js: your layout loads it, with the libraries
    const bindings = (Nitro) => {
        Nitro.directive('select2', ({ el, value, component }) => {
            const $el = $(el);
    
            // 1. Start it with the property's value.
            $el.val(component.get(value)).select2();
    
            // 2. The visitor chose: set the property.
            $el.on('change.nitro', () => component.set(value, $el.val()));
    
            // 3. Anything else changed the property: show it (the namespaced event tells select2 only).
            const stop = component.watch(value, (now) => $el.val(now).trigger('change.select2'));
    
            // 4. Leaving the page, an @if, a removed row: tear it down.
            return () => {
                stop();
                $el.off('change.nitro').select2('destroy');
            };
        });
    
        // ... the other libraries' bindings
    };
    
    // Loaded before Nitro (a page's first load): when it starts. Loaded by a visit, after it: now.
    if (window.Nitro) bindings(window.Nitro);
    else document.addEventListener('nitro:starting', ({ detail: { Nitro } }) => bindings(Nitro));
    
  3. Use it

    Blade
    <div nitro:ignore>
        <select nitro:select2="category">
            @foreach ($categories as $category)
                <option value="{{ $category->id }}">{{ $category->name }}</option>
            @endforeach
        </select>
    </div>
    
    {{-- A row of a list: bound to its own row. --}}
    @foreach ($lines as $i => $line)
        <div nitro:key="line-{{ $i }}" nitro:ignore>
            <select nitro:select2="lines.{{ $i }}.product">...</select>
        </div>
    @endforeach
    
The component Does
component.get(path) The property's value ( lines.2.product : a key under it), as plain data
component.set(path, value) Sets it, as nitro:model does: its updated*() hooks run, and the view renders the change
component.watch(path, fn) Calls fn(value) whenever the value changes from anywhere else: a browser method, the server's answer, a reset, another library on the same property. Not for this binding's own set() , so the value never echoes back. Returns a function that stops watching
component.call(method, ...args) Runs a method: a browser one returns its value, a #[Server] one a promise of it
component.dispatch(event, ...) Sends an event, as $this->dispatch() does

Why the namespaced event

select2 reports a choice with jQuery's change event, and so does your own .trigger(). Listening to change.nitro and triggering change.select2 keeps the two apart: what the visitor did reaches the property, what the property did reaches select2 only. Most libraries have the same: a quiet way to set the value (Flatpickr's setDate(value, false), Tom Select's setValue(value, true)).

What Nitro takes care of

Write the binding once, and these work with no more code:

  • A visit. nitro:navigate to another page tears every library down; back, each starts again, once, with the page's values. There is no navigated event to listen for, and nothing starts twice. The back button too.
  • Arriving by a visit. From a page of the same layout, the libraries start as the page shows. From a page without them, into a page whose layout loads them, the libraries and your bindings load with the visit, after the page's components started: Nitro runs a binding registered then on the elements already there.
  • An @if. When the block goes, its libraries are torn down; when it comes back, they start with the property's value at that moment.
  • A list. A new row's library starts, bound to its own row; a removed row's goes.
  • Renders. A render of anything else leaves the library and its markup as they are.
  • The server. A #[Server] method that changes the property reaches the library with its answer.
  • An obfuscated build. The binding uses your property names; Nitro translates them.

Nitro's own browser tests run select2, Flatpickr, Tom Select, Choices, Trix, Quill, SortableJS, noUiSlider and IMask through each of these, every way into the page, with the bindings on this page.

Recipes

Flatpickr

JavaScript
Nitro.directive('flatpickr', ({ el, value, component }) => {
    const picker = flatpickr(el, {
        dateFormat: 'Y-m-d',
        defaultDate: component.get(value),
        onChange: (dates, text) => component.set(value, text),
    });
    const stop = component.watch(value, (now) => picker.setDate(now, false));   // false: no onChange

    return () => {
        stop();
        picker.destroy();
    };
});

Tom Select

JavaScript
Nitro.directive('tom-select', ({ el, value, component }) => {
    const select = new TomSelect(el, {});
    select.setValue(component.get(value), true);                       // true: silent
    select.on('change', (now) => component.set(value, now));
    const stop = component.watch(value, (now) => select.setValue(now, true));

    return () => {
        stop();
        select.destroy();
    };
});

Choices

JavaScript
Nitro.directive('choices', ({ el, value, component }) => {
    const choices = new Choices(el, { shouldSort: false });
    choices.setChoiceByValue(component.get(value));
    const changed = () => component.set(value, choices.getValue(true));
    el.addEventListener('change', changed);
    const stop = component.watch(value, (now) => choices.setChoiceByValue(now));

    return () => {
        stop();
        el.removeEventListener('change', changed);
        choices.destroy();
    };
});

Trix

Trix needs a hidden input beside its editor; the binding makes both, so the view has one element:

JavaScript
// <div nitro:ignore><div nitro:trix="body"></div></div>
Nitro.directive('trix', ({ el, value, component }) => {
    const input = Object.assign(document.createElement('input'), { type: 'hidden', id: `trix-${Math.random().toString(36).slice(2)}`, value: component.get(value) ?? '' });
    const editor = document.createElement('trix-editor');
    editor.setAttribute('input', input.id);
    el.append(input, editor);

    let loading = false;
    const changed = () => loading || component.set(value, input.value);
    editor.addEventListener('trix-change', changed);
    const stop = component.watch(value, (now) => {
        loading = true;
        editor.editor.loadHTML(now ?? '');
        loading = false;
    });

    return () => {
        stop();
        editor.removeEventListener('trix-change', changed);
        el.innerHTML = '';
    };
});

Quill

JavaScript
Nitro.directive('quill', ({ el, value, component }) => {
    const quill = new Quill(el, { theme: 'snow' });
    const show = (html) => quill.setContents(quill.clipboard.convert({ html: html ?? '' }), 'silent');
    show(component.get(value));
    quill.on('text-change', (delta, old, source) => source === 'user' && component.set(value, quill.root.innerHTML));
    const stop = component.watch(value, show);

    return () => {
        stop();
        el.innerHTML = '';
    };
});

SortableJS

The library moves the list's items itself, so the binding draws them from the property, and the property keeps the order:

JavaScript
// <div nitro:ignore><ul nitro:sortable="order"></ul></div>: the list is drawn here, from the property
Nitro.directive('sortable', ({ el, value, component }) => {
    el.innerHTML = component.get(value).map((id) => `<li data-id="${id}">${id}</li>`).join('');
    const sortable = Sortable.create(el, { onEnd: () => component.set(value, sortable.toArray()) });
    const stop = component.watch(value, (order) => sortable.sort(order, false));

    return () => {
        stop();
        sortable.destroy();
    };
});

noUiSlider and IMask

JavaScript
Nitro.directive('slider', ({ el, value, component }) => {
    noUiSlider.create(el, { start: component.get(value), step: 1, range: { min: 0, max: 100 } });
    el.noUiSlider.on('set', ([now]) => Number(now) !== component.get(value) && component.set(value, Math.round(Number(now))));
    const stop = component.watch(value, (now) => el.noUiSlider.set(now));

    return () => {
        stop();
        el.noUiSlider.destroy();
    };
});

Nitro.directive('mask', ({ el, value, component }) => {
    const mask = IMask(el, { mask: '0000 0000 0000 0000' });
    mask.unmaskedValue = component.get(value) ?? '';          // the property holds the digits
    mask.on('accept', () => mask.unmaskedValue !== component.get(value) && component.set(value, mask.unmaskedValue));
    const stop = component.watch(value, (now) => { mask.unmaskedValue = now ?? ''; });

    return () => {
        stop();
        mask.destroy();
    };
});

One component's script: @nitroScript

When a library belongs to one component only, such as a chart, write its code in the component's view. @nitroScript runs once for each instance of the component, as it goes live:

Blade
<div>
    <canvas nitro:ref="chart"></canvas>

    @nitroScript
    <script>
        const chart = new Chart($refs.chart, {
            type: 'line',
            data: { labels: $component.get('months'), datasets: [{ data: $component.get('views') }] },
        });

        const stop = $component.watch('views', (views) => {
            chart.data.datasets[0].data = views;
            chart.update();
        });

        return () => {
            stop();
            chart.destroy();
        };
    </script>
    @endNitroScript
</div>
  • What it can use. $refs (elements marked nitro:ref), $el (the component's root element), $component (the same get, set, watch as above) and $nitro (the component as $this->js() has it: $nitro.views, $nitro.save()).
  • Once for each instance. Two charts on a page run it twice, each with its own $refs. A render never runs it again; a new instance (after a visit, in an @if that comes back) does.
  • Cleaning up. A function it returns runs when the component leaves the page.
  • In the root element, outside blocks. Inside an @if or a @foreach it is a build error: whether it ran would depend on the block.
  • Never on the server. The server renders nothing of it, and Blade never reads it, so {{ }} and @@ in your JavaScript are just JavaScript. The <script> tag is optional, for your editor.

Loading the library itself

Load the library (jQuery, select2, Flatpickr), then your bindings, once, in your layout or your Vite bundle. A binding or a @nitroScript only uses the library. A layout of its own can load them in its <head>: a visit into it loads them, in their order.

Next steps