Last active
May 4, 2022 20:08
-
-
Save jdanyow/406236cec2ee07160aba0c458fa52a22 to your computer and use it in GitHub Desktop.
Star rating web component - slot refactor 5/3 - variation 2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>GistRun</title> | |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@microsoft/atlas-css/dist/index.css"> | |
<!-- fake design system styles or application styles to illustrate styling the "public interface" of the web component (the component itself as well as "parts" it's exposed for styling) --> | |
<style> | |
.visually-hidden, | |
*::part(visually-hidden) { | |
clip: rect(0 0 0 0); | |
clip-path: inset(50%); | |
height: 1px; | |
overflow: hidden; | |
position: absolute; | |
white-space: nowrap; | |
width: 1px; | |
} | |
.custom-star-rating-1 { | |
color: darkcyan; | |
} | |
.custom-star-rating-2 { | |
color: chocolate; | |
} | |
.custom-star-rating-2::part(star) { | |
color: deeppink; | |
stroke: rebeccapurple; | |
} | |
</style> | |
</head> | |
<body> | |
<!-- application html --> | |
<form class="padding-sm" id="rating-form"> | |
<div class="margin-bottom-sm"> | |
<label for="pet-name">Pet name</label> | |
<input type="text" id="pet-name" name="pet-name" /> | |
</div> | |
<fieldset class="margin-bottom-sm"> | |
<legend>How are we doing?</legend> | |
<label><input type="radio" name="standard"> Terrible</label> | |
<label><input type="radio" name="standard"> Poor</label> | |
<label><input type="radio" name="standard"> Fair</label> | |
<label><input type="radio" name="standard"> Good</label> | |
<label><input type="radio" name="standard"> Great</label> | |
</fieldset> | |
<star-rating class="margin-bottom-sm" name="rating-1" value="4"> | |
<span slot="legend">How are we doing?</span> | |
<span slot="label-1">Terrible</span> | |
<span slot="label-2">Poor</span> | |
<span slot="label-3">Fair</span> | |
<span slot="label-4">Good</span> | |
<span slot="label-5">Great</span> | |
</star-rating> | |
<star-rating class="theme-dark padding-sm margin-bottom-sm" name="rating-1" value="4"> | |
<span slot="legend">How are <strong>we doing</strong>?</span> | |
<span slot="label-1">Terrible</span> | |
<span slot="label-2">Poor</span> | |
<span slot="label-3">Fair</span> | |
<span slot="label-4">Good</span> | |
<span slot="label-5">Great</span> | |
</star-rating> | |
<star-rating class="margin-bottom-sm custom-star-rating-1" name="rating-2" value="1"> | |
<span slot="legend">Did this answer tickle your fancy?</span> | |
<span slot="label-1">Not in the <strong>slightest</strong></span> | |
<span slot="label-2">No</span> | |
<span slot="label-3">Maybe</span> | |
<span slot="label-4">A bit</span> | |
<span slot="label-5">Yes!</span> | |
</star-rating> | |
<star-rating class="margin-block-sm custom-star-rating-2" name="rating-3" value="2"> | |
<span slot="legend">Did this web component make you <strong>smile</strong>?</span> | |
<span slot="label-1">Not in the slightest</span> | |
<span slot="label-2">No</span> | |
<span slot="label-3">Maybe</span> | |
<span slot="label-4">A bit</span> | |
<span slot="label-5">Yes!</span> | |
</star-rating> | |
<star-rating class="margin-block-sm" id="disabled-rating" name="rating-4" value="3" disabled required> | |
<span slot="legend">Enabling and disabling, value and name changing, via javascript api <span style="color: red">(REQUIRED)</span></span> | |
<span slot="label-1">Not in the slightest</span> | |
<span slot="label-2">No</span> | |
<span slot="label-3">Maybe</span> | |
<span slot="label-4">A bit</span> | |
<span slot="label-5">Yes!</span> | |
</star-rating> | |
<button type="submit" class="button">Submit</button> | |
<ul class="margin-block-sm"> | |
<li><a href="https://web.dev/more-capable-form-controls/">https://web.dev/more-capable-form-controls/</a></li> | |
<li><a href="https://dev.to/43081j/using-css-shadow-parts-in-web-components-7h5">https://dev.to/43081j/using-css-shadow-parts-in-web-components-7h5</a></li> | |
<li><a href="https://css-tricks.com/styling-web-components/">https://css-tricks.com/styling-web-components/</a></li> | |
<li><a href="https://medium.com/swlh/adopt-a-design-system-inside-your-web-components-with-constructable-stylesheets-dd24649261e">https://medium.com/swlh/adopt-a-design-system-inside-your-web-components-with-constructable-stylesheets-dd24649261e</a></li> | |
</ul> | |
</form> | |
<!-- application code --> | |
<script> | |
const form = document.getElementById('rating-form'); | |
form.addEventListener('submit', event => { | |
event.preventDefault(); | |
// read the form data and convert it to an object. | |
const data = Object.fromEntries(new FormData(form)); | |
// display the data | |
alert(JSON.stringify(data, null, 2)) | |
}); | |
// try out the javascript api (name, value, disabled) | |
setInterval(() => { | |
const rating = document.getElementById('disabled-rating'); | |
rating.disabled = !rating.disabled; | |
rating.value = rating.value === '' ? '5' : parseInt(rating.value) - 1; | |
rating.name = `rating-${new Date().toISOString()}`; | |
}, 1000); | |
</script> | |
<!-- web component template --> | |
<template id="star-rating-template"> | |
<style type="text/css"> | |
*, ::before, ::after { | |
box-sizing: border-box; | |
} | |
:host { | |
display: block; | |
} | |
fieldset { | |
display: flex; | |
gap: 3px; | |
align-items: center; | |
border: none; | |
margin: 0; | |
padding: 0; | |
} | |
svg { | |
fill: none; | |
stroke: currentColor; | |
} | |
label:hover > svg, | |
input:checked + label > svg { | |
fill: currentColor; | |
} | |
input:focus-visible + label { | |
outline-width: 2px; | |
outline-style: dashed; | |
} | |
/* checked styles */ | |
[id^="label-"] { | |
display: none; | |
} | |
input[value="1"]:checked ~ #alert #label-1, | |
input[value="2"]:checked ~ #alert #label-2, | |
input[value="3"]:checked ~ #alert #label-3, | |
input[value="4"]:checked ~ #alert #label-4, | |
input[value="5"]:checked ~ #alert #label-5 { | |
display: inline; | |
} | |
/* override checked styles with hover styles */ | |
input:hover ~ #alert [id^="label-"] { | |
display: none !important; | |
} | |
input[value="1"]:hover ~ #alert #label-1, | |
input[value="2"]:hover ~ #alert #label-2, | |
input[value="3"]:hover ~ #alert #label-3, | |
input[value="4"]:hover ~ #alert #label-4, | |
input[value="5"]:hover ~ #alert #label-5 { | |
display: inline !important; | |
} | |
/* disabled styles */ | |
:disabled svg { | |
stroke: gray !important; | |
} | |
:disabled input:checked + label > svg { | |
fill: gray !important; | |
} | |
</style> | |
<fieldset> | |
<legend> | |
<slot name="legend">Enter rating</slot> | |
</legend> | |
<input type="radio" value="1" id="radio-1" part="visually-hidden" /> | |
<label for="radio-1"> | |
<svg part="star" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 51 48"><path d="m25,1 6,17h18l-14,11 5,17-15-10-15,10 5-17-14-11h18z"/></svg> | |
<span part="visually-hidden">1<!-- default label, will be replaced by slot content if provided --></span> | |
</label> | |
<input type="radio" value="2" id="radio-2" part="visually-hidden" /> | |
<label for="radio-2"> | |
<svg part="star" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 51 48"><path d="m25,1 6,17h18l-14,11 5,17-15-10-15,10 5-17-14-11h18z"/></svg> | |
<span part="visually-hidden">2<!-- default label, will be replaced by slot content if provided --></span> | |
</label> | |
<input type="radio" value="3" id="radio-3" part="visually-hidden" /> | |
<label for="radio-3"> | |
<svg part="star" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 51 48"><path d="m25,1 6,17h18l-14,11 5,17-15-10-15,10 5-17-14-11h18z"/></svg> | |
<span part="visually-hidden">3<!-- default label, will be replaced by slot content if provided --></span> | |
</label> | |
<input type="radio" value="4" id="radio-4" part="visually-hidden" /> | |
<label for="radio-4"> | |
<svg part="star" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 51 48"><path d="m25,1 6,17h18l-14,11 5,17-15-10-15,10 5-17-14-11h18z"/></svg> | |
<span part="visually-hidden">4<!-- default label, will be replaced by slot content if provided --></span> | |
</label> | |
<input type="radio" value="5" id="radio-5" part="visually-hidden" /> | |
<label for="radio-5"> | |
<svg part="star" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 51 48"><path d="m25,1 6,17h18l-14,11 5,17-15-10-15,10 5-17-14-11h18z"/></svg> | |
<span part="visually-hidden">5<!-- default label, will be replaced by slot content if provided --></span> | |
</label> | |
<span id="alert"> | |
<span id="label-1"><slot name="label-1"></slot></span> | |
<span id="label-2"><slot name="label-2"></slot></span> | |
<span id="label-3"><slot name="label-3"></slot></span> | |
<span id="label-4"><slot name="label-4"></slot></span> | |
<span id="label-5"><slot name="label-5"></slot></span> | |
</span> | |
</fieldset> | |
</template> | |
<!-- web component javascript --> | |
<script> | |
const template = document.getElementById("star-rating-template"); | |
class StarRatingElement extends HTMLElement { | |
static get observedAttributes() { return ['name', 'value', 'disabled', 'required']; } | |
coercedValue = ''; | |
constructor() { | |
super(); | |
this.attachShadow({ mode: "open" }); | |
this.shadowRoot.appendChild(template.content.cloneNode(true)); | |
partPolyfill(this.shadowRoot); | |
} | |
get type() { return 'star-rating'; } | |
get name() { return this.getAttribute('name') ?? ''; } | |
set name(value) { this.setAttribute('name', value); } | |
get value() { return this.coercedValue; }; | |
set value(value) { | |
value = String(value); | |
this.coercedValue = ['', '1', '2', '3', '4', '5'].includes(value) ? value : ''; | |
const checkbox = this.shadowRoot.querySelector(`[value="${this.coercedValue}"]`); | |
if (checkbox){ | |
checkbox.checked = true; | |
} else { | |
const uncheck = this.shadowRoot.querySelector(':checked'); | |
if (uncheck) { | |
uncheck.checked = false; | |
} | |
} | |
} | |
get disabled() { return this.hasAttribute('disabled'); } | |
set disabled(value) { this.toggleAttribute('disabled', value); } | |
get required() { return this.hasAttribute('required'); } | |
set required(value) { this.toggleAttribute('required', value); } | |
get validity() { return this.shadowRoot.querySelector('input').validity; } | |
connectedCallback() { | |
this.shadowRoot.addEventListener('change', this); | |
this.closest('form')?.addEventListener('formdata', this); | |
this.shadowRoot.addEventListener('slotchange', this); | |
} | |
disconnectedCallback() { | |
this.shadowRoot.removeEventListener('change', this); | |
this.closest('form')?.removeEventListener('formdata', this); | |
this.shadowRoot.removeEventListener('slotchange', this); | |
} | |
attributeChangedCallback(name, oldValue, newValue) { | |
if (name === 'value') { | |
this.value = newValue; | |
} else if (name === 'disabled') { | |
this.shadowRoot.querySelector('fieldset').disabled = newValue !== null; | |
} else if (name === 'name') { | |
this.shadowRoot.querySelectorAll('input[type="radio"]').forEach(input => input.name = newValue); | |
} else if (name === 'required') { | |
this.shadowRoot.querySelectorAll('input[type="radio"]').forEach(input => input.required = newValue !== null); | |
} | |
console.log(`name: "${this.name}""; value: "${this.value}"; disabled: ${this.disabled}; required: ${this.required}; validity.valueMissing: ${this.validity.valueMissing};`); | |
} | |
handleEvent(event) { | |
switch(event.type) { | |
case 'change': | |
this.setAttribute('value', event.target.value); | |
this.dispatchEvent(new Event('change', { bubbles: true })); | |
break; | |
case 'formdata': | |
// https://web.dev/more-capable-form-controls/ | |
event.formData.append(this.name, this.value); | |
break; | |
case 'slotchange': { | |
// when the label slots get new content, clone it into the label element's visually-hidden span | |
const slot = event.target; | |
if (!slot.name.startsWith('label-')) { | |
break; | |
} | |
const radioNumber = slot.name.substr(6); | |
const labelContentContainer = this.shadowRoot.querySelector(`[for="radio-${radioNumber}"] [part="visually-hidden"]`); | |
labelContentContainer.innerHTML = ''; | |
if (slot.assignedNodes().length) { | |
labelContentContainer.append(...Array.from(slot.assignedNodes()).map(node => node.cloneNode(true))); | |
} | |
break; | |
} | |
} | |
} | |
} | |
customElements.define('star-rating', StarRatingElement); | |
// need to test in browserstack... | |
function partPolyfill(root) { | |
// Firefox for Android does not support the "part" attribute but it does support the DOM property. | |
// Copy the attribute value to the DOM property... | |
// https://caniuse.com/mdn-html_global_attributes_part | |
root.querySelectorAll('[part]').forEach(el => { el.part = el.getAttribute('part'); }); | |
} | |
</script> | |
</body> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
console.log('Hello World!'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* todo: add styles */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment