PAD + web components

A rating that posts

<pad-rating value="4"> gets its value from PAD and its stars from the server; a click posts the new rating with the session's CSRF token, and the element shows the average PAD answers.

padSession$padExpose attributeChangedCallbackfetch POST
Live
🎧 Aurora Headphones 4.5 on average, 4 votes
⌨️ Tactile Keyboard 4.5 on average, 2 votes
💡 Ember Desk Lamp 3.5 on average, 2 votes

How it is built

PAD, on the server the browser

<div class="grid">
{products}
  <div class="card product">
    <span class="product-emoji">{$emoji}</span>
    <strong>{$name}</strong>
    {rating name=$id, value=$mine, average=$average, votes=$votes}
  </div>
{/products}
</div>

<script type="module" src="{$padRoot}webcomponents/elements/rating.js"></script>
<?php

  // The votes are the session's: product id => [ the sum, the count ], started from a few
  // votes of others. The rating of a product is the visitor's own last vote; a click posts
  // the product and the stars, and the answer is the new average ($padExpose).

  $products = [ [ 'id' => 1, 'name' => 'Aurora Headphones', 'emoji' => '🎧' ],
                [ 'id' => 2, 'name' => 'Tactile Keyboard',  'emoji' => '⌨️' ],
                [ 'id' => 3, 'name' => 'Ember Desk Lamp',   'emoji' => '💡' ] ];

  $votes = padSession ( 'wcVotes', [ 1 => [ 'sum' => 18, 'count' => 4, 'mine' => 0 ],
                                     2 => [ 'sum' => 9,  'count' => 2, 'mine' => 0 ],
                                     3 => [ 'sum' => 7,  'count' => 2, 'mine' => 0 ] ] );

  // Asked for its JSON without a vote, the page answers no rating; a vote for a product it
  // does not have is a 404 - where both were a 500, the one for a $rating never set, the
  // other for the votes of product 99.

  $rating = NULL;

  if ( padRequestIs ( 'POST' ) ) {

    $id    = (int) padRequest ( 'id', 0 );
    $value = (int) padRequest ( 'value', 0 );

    if ( ! isset ( $votes [$id] ) )
      padAbort ( 404, 'There is no such product' );

    if ( $value >= 1 and $value <= 5 ) {
      $votes [$id] ['sum']  += $value - $votes [$id] ['mine'];
      $votes [$id] ['count'] += $votes [$id] ['mine'] ? 0 : 1;
      $votes [$id] ['mine']  = $value;
      padSessionPut ( 'wcVotes', $votes );
    }

    $rating = [ 'average' => round ( $votes [$id] ['sum'] / max ( 1, $votes [$id] ['count'] ), 1 ),
                'votes'   => $votes [$id] ['count'] ];

  }

  foreach ( $products as $at => $product )
    $products [$at] += [ 'mine'    => $votes [ $product ['id'] ] ['mine'],
                         'average' => round ( $votes [ $product ['id'] ] ['sum'] / $votes [ $product ['id'] ] ['count'], 1 ),
                         'votes'   => $votes [ $product ['id'] ] ['count'] ];

  $padExpose = [ 'rating' ];

?>
{parms name, value=0, average=0, votes=0}
<pad-rating name="{#name}" value="{#value}">
  {shadow css='www:elements/rating.css'}
    <div class="stars" part="stars" role="radiogroup">
      <button data-value="1" aria-label="1 star">★</button><button data-value="2" aria-label="2 stars">★</button><button data-value="3" aria-label="3 stars">★</button><button data-value="4" aria-label="4 stars">★</button><button data-value="5" aria-label="5 stars">★</button>
    </div>
    <span class="info"><slot></slot></span>
  {/shadow}
  {#average} on average, {#votes} votes
</pad-rating>
// <pad-rating>: the stars are in the shadow root PAD rendered, lit by the value attribute. A
// click sets the attribute and posts the vote to the page with the session's CSRF token; the
// text in the light DOM becomes the average PAD answers.

class PadRating extends HTMLElement {

  static observedAttributes = ['value'];

  connectedCallback() {
    this.shadowRoot.addEventListener('click', (event) => {
      const star = event.target.closest('button[data-value]');
      if (star) this.vote(Number(star.dataset.value));
    });
  }

  attributeChangedCallback(name, before, now) {
    this.shadowRoot?.querySelectorAll('button').forEach((star) =>
      star.setAttribute('aria-pressed', String(Number(star.dataset.value) <= Number(now))));
  }

  async vote(value) {
    this.setAttribute('value', value);
    const token = document.querySelector('meta[name="csrf-token"]').content;
    const response = await fetch('?examples/rating&padFormat=json', {
      method: 'POST',
      headers: { 'Accept': 'application/json', 'X-CSRF-Token': token },
      body: new URLSearchParams({ id: this.getAttribute('name'), value })
    });
    if (!response.ok) return;
    const { rating } = await response.json();
    this.textContent = `${rating.average} on average, ${rating.votes} votes - yours: ${value}`;
  }
}

customElements.define('pad-rating', PadRating);
/* The shadow root of <pad-rating>. The stars of the value light up by the attribute alone, so
   the rating shows right before rating.js has run. */

:host { display: grid; gap: 6px; }
.stars { display: flex; gap: 2px; }
button { all: unset; cursor: pointer; font-size: 26px; line-height: 1; color: color-mix(in srgb, var(--faint, #999) 55%, transparent); transition: transform .1s ease, color .15s ease; }
button:hover { transform: scale(1.15); }
button:focus-visible { outline: 2px solid var(--accent, #e8590c); border-radius: 4px; }
:host([value="1"]) button:nth-child(-n+1),
:host([value="2"]) button:nth-child(-n+2),
:host([value="3"]) button:nth-child(-n+3),
:host([value="4"]) button:nth-child(-n+4),
:host([value="5"]) button:nth-child(-n+5) { color: #f5a623; }
.stars:hover button { color: #f5a623; }
.stars button:hover ~ button { color: color-mix(in srgb, var(--faint, #999) 55%, transparent); }
.info { font-size: 13px; color: var(--muted, #666); }