PAD + Alpine

A list PAD keeps

Every change shows at once and is posted to the page, which keeps the list in the session and answers it - the server's answer is the list. Reload, and it is still there.

padSession$padExpose x-for@submit.preventfetch POST
Live

of done · saving ...

  • Read the guide about braces
  • Hand Alpine its state with {^}
  • Let PAD keep the list
Requests to PAD
    Nothing yet - use the example above, and every request a component makes to PAD shows here.

    How it is built

    PAD, on the server Alpine, in the browser

    <div class="todos" x-data="todos({^todos})">
    
      <form class="todo-add" @submit.prevent="add()">
        <input class="input" x-model="text" placeholder="Something to do ..." aria-label="A new todo" maxlength="80">
        <button>Add</button>
      </form>
    
      <div class="bar"><span :style="'width:' + progress() + '%'"></span></div>
      <p class="muted small"><span x-text="done()"></span> of <span x-text="list.length"></span> done
         <span x-show="syncing" x-cloak> &middot; saving ...</span></p>
    
      <ul class="todo-list">
        <template x-for="todo in list" :key="todo.id">
          <li :class="todo.done && 'is-done'">
            <label><input type="checkbox" :checked="todo.done" :disabled="!!todo.pending" @change="toggle(todo)"> <span x-text="todo.text"></span></label>
            <button class="btn-icon btn-ghost" @click="remove(todo)" :disabled="!!todo.pending" :aria-label="'Remove ' + todo.text">×</button>
          </li>
        </template>
      </ul>
    
      <button class="btn-sm btn-ghost" @click="clear()" x-show="done() > 0" x-cloak>Clear the done ones</button>
    
      <ul class="todo-list" x-show="false">
        {todos}<li>{$text}</li>{/todos}
      </ul>
    </div>
    
    <script src="{$padRoot}alpine/examples/todos.js"></script>
    <?php
    
      // The list is the session's, started from _data/todos.json. Every change is a post - op=add
      // with a text, toggle or remove with an id, clear for the done ones - and the answer is
      // the list as it now stands ($padExpose): the component shows a change at once and then
      // takes the server's list as the truth.
    
      $todos = padSession ( 'alpineTodos', alpineData ( 'todos' ) );
    
      if ( padRequestIs ( 'POST' ) ) {
    
        $op   = alpineText ( 'op' );
        $id   = (int) padRequest ( 'id', 0 );
        $text = trim ( alpineText ( 'text' ) );
    
        if ( $op == 'add' and $text !== '' )
          $todos [] = [ 'id' => max ( array_column ( $todos, 'id' ) ?: [ 0 ] ) + 1, 'text' => mb_substr ( $text, 0, 80 ), 'done' => FALSE ];
    
        foreach ( $todos as $at => $todo ) {
          if ( $op == 'toggle' and $todo ['id'] == $id ) $todos [$at] ['done'] = ! $todo ['done'];
          if ( $op == 'remove' and $todo ['id'] == $id ) unset ( $todos [$at] );
        }
    
        if ( $op == 'clear' )
          $todos = array_filter ( $todos, fn ( $todo ) => ! $todo ['done'] );
    
        $todos = array_values ( $todos );
    
        padSessionPut ( 'alpineTodos', $todos );
    
      }
    
      $padExpose = [ 'todos' ];
    
    ?>
    // The list: what PAD wrote into the argument - todos({^todos}) - and from then on what PAD
    // answers. A change shows at once; the post that follows answers the whole list, which
    // replaces the component's own. If the post fails, the server's list stands.
    //
    // A todo just added has no id of the server's until the answer to its post is in: it shows
    // with a key of its own and cannot be ticked or removed yet. It was id 'new' - a toggle or
    // remove of it posted id=new, which matched nothing, so the change was lost when the answer
    // came, and two quick adds were two items with the key 'new'.
    
    document.addEventListener('alpine:init', () => {
    
      Alpine.data('todos', (first) => ({
        list: first,
        text: '',
        syncing: false,
        added: 0,
    
        done() { return this.list.filter(todo => todo.done).length; },
        progress() { return this.list.length ? Math.round(this.done() / this.list.length * 100) : 0; },
    
        async sync(body) {
          this.syncing = true;
          try {
            const answer = await pad.post('examples/todos', body);
            this.list = answer.todos;
          } catch (error) {
            const answer = await pad.get('examples/todos');
            this.list = answer.todos;
          } finally {
            this.syncing = false;
          }
        },
    
        add() {
          const text = this.text.trim();
          if (!text) return;
          this.list.push({ id: 'new-' + ++this.added, text, done: false, pending: true });
          this.text = '';
          this.sync({ op: 'add', text });
        },
    
        toggle(todo) {
          if (todo.pending) return;
          todo.done = !todo.done;
          this.sync({ op: 'toggle', id: todo.id });
        },
    
        remove(todo) {
          if (todo.pending) return;
          this.list = this.list.filter(one => one !== todo);
          this.sync({ op: 'remove', id: todo.id });
        },
    
        clear() {
          this.list = this.list.filter(todo => !todo.done);
          this.sync({ op: 'clear' });
        }
      }));
    
    });