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); }