Skip to content

Instantly share code, notes, and snippets.

@sketchpunk
Last active June 24, 2026 23:15
Show Gist options
  • Select an option

  • Save sketchpunk/57a473fc63cab47dc99ee225782ab819 to your computer and use it in GitHub Desktop.

Select an option

Save sketchpunk/57a473fc63cab47dc99ee225782ab819 to your computer and use it in GitHub Desktop.
Web Components
class ColorInput extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'value' ];
#inpColor;
constructor(){
super();
this.attachShadow({ mode: 'open' });
// Build UI
this.shadowRoot.innerHTML = `
<style>
:host{ display:flex; flex-direction:row; align-items:center; }
.elm {
flex : 1 1 auto;
min-width : 0px;
appearance : none; -webkit-appearance: none;
padding : 0px;
margin : 0px;
border : none;
cursor : pointer;
height : 1.5em;
}
.elm::-webkit-color-swatch-wrapper { padding : 0px; }
.elm::-webkit-color-swatch,
.elm::-moz-color-swatch {
border : 1px solid #666;
border-radius : 2px;
}
</style>
<input type="color" name="colorPicker" part="cinput" class="elm" value="#000000">
`;
this.#inpColor = this.shadowRoot.querySelector('input[name="colorPicker"]');
this.#inpColor.addEventListener( 'input', this.onHandler );
this.#inpColor.addEventListener( 'change', this.onHandler );
}
// #endregion
// #region BASE OVERRIDES
// Handle changes to element attributes
attributeChangedCallback( name, oldValue, newValue ){
switch( name ){
case 'value': this.value = newValue; break;
}
}
// #endregion
// #region GETTERS / SETTERS
get value(){ return this.#inpColor.value; }
set value( v ){ this.#inpColor.value = v; }
// Convert hex color to RGB array ( 0-255 range )
get rgb(){
const hex = this.#inpColor.value;
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
// Convert hex color to normalized RGB array ( 0-1 range )
get rgbNorm(){
const hex = this.#inpColor.value;
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
}
// Convert hex color to HSL array ( H: 0-360, S: 0-100, L: 0-100 )
get hsl(){
const [r, g, b] = this.rgbNorm;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let l = ( max + min ) / 2;
let h=0, s=0;
if( max === min ) h = s = 0; // achromatic
else{
const d = max - min;
s = l > 0.5
? d / ( 2 - max - min )
: d / ( max + min );
switch( max ){
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [
Math.round( h * 360 ),
Math.round( s * 100 ),
Math.round( l * 100 ),
];
}
// #endregion
// #region EVENT HANDLERS
onHandler = e =>{
e.preventDefault();
e.stopPropagation();
this.dispatchEvent( new CustomEvent( e.type, {
bubbles : true,
cancelable : true,
detail: {
target : this,
value : this.value,
},
} ) );
}
// #endregion
}
window.customElements.define( 'color-input', ColorInput );
/*
const btn = group.newHeaderButton( true );
btn.textContent = '👁';
btn.title = 'BlaBla';
btn.style.fontSize = '1.5rem';
btn.addEventListener( 'click', () => {
const isOn = !this.#state.edgeRenderActive;
this.#state.edgeRenderActive = isOn;
btn.isDim = !isOn;
});
*/
class GroupSection extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'close', 'label' ];
constructor(){
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host{
--bg-color: #808080;
}
:host{ display:flex; flex-direction:column; border: 1px solid var(--bg-color); }
:host > header{
display: flex; flex-direction: row; justify-content: space-between; align-items: center;
background-color: var(--bg-color);
font-weight: bold; color: white;
padding: 2px 6px;
user-select: none;
}
/* HEADER BAR */
:host > header i:after{ display: inline-block; content: '▲'; }
:host([close]) > header i:after{ content: '▼'; }
:host > header > div{ display:flex; flex-direction:row-reverse; gap:5px; }
:host > header button{
border:0px;
margin:0px;
padding:1px 3px;
border-radius:3px;
background-color:transparent;
line-height:0.5rem;
color:white;
cursor:pointer;
&.dim{ color: #303030; }
&:active{ background-color: #606060; }
}
/* CONTENT AREA */
:host([close]) > slot{ display:none; }
:host > slot{
display: flex; flex-direction:column; gap:5px;
padding: 5px;
}
</style>
<header><span>TITLE</span><div><i></i></div></header>
<slot></slot>`;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Bind Events
this.shadowRoot.querySelector('header').addEventListener( 'click', this.toggle );
}
// #endregion
// #region BASE OVERRIDES
// Handle changes to element attributes
attributeChangedCallback( name, oldValue, newValue ){
switch( name ){
case 'label' :
this.shadowRoot.querySelector(':host > header > span').textContent = newValue;
break;
}
}
// #endregion
// #region METHODS
toggle = e=>{
if( e.target.tagName === 'BUTTON' ) return; // Ignore clicks on buttons in the header
if( this.hasAttribute('close') ) this.removeAttribute('close');
else this.setAttribute('close', '');
}
newHeaderButton( isDim=false){
const btn = document.createElement('button');
Object.defineProperty( btn, 'isDim', {
set : function( b ){ this.classList.toggle( 'dim', b ) },
get : function(){ return this.classList.contains( 'dim' ) },
configurable : false,
enumerable : false,
});
if( isDim ) btn.isDim = true;
this.shadowRoot.querySelector( 'header > div' ).appendChild( btn );
return btn;
}
// #endregion
}
class MultiSelectInput extends HTMLElement {
// #region MAIN
static observedAttributes = [ 'size' ];
#listbox;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
--item-height : 24px;
--visible-items : 4;
display : grid;
overflow-y : auto;
border : 1px solid #555;
background : white;
height : calc(var(--item-height) * var(--visible-items));
}
.listbox{ outline: none; }
.option{
display : flex;
flex-direction : row;
align-items : center;
gap : 6px;
height : var(--item-height);
line-height : var(--item-height);
padding : 0px 6px;
cursor : pointer;
user-select : none;
&:hover { background-color: #c0c0c0; color:white; }
& > svg { flex: 0 0 14px; }
& > svg polyline{ stroke: transparent; }
& > span { flex: 1 1 auto; }
}
.option[aria-selected="true"]{
& > svg polyline{ stroke: white; }
}
</style>
<section class="listbox" role="listbox" aria-multiselectable="true" tabindex="0"></section>
`;
this.#listbox = this.shadowRoot.querySelector( 'section' );
this.#listbox.addEventListener( 'pointerdown', this.#onPointerDown );
}
// #endregion
// #region BASE OVERRIDES
attributeChangedCallback( name, oldValue, newValue ){
switch( name ){
case 'size': this.style.setProperty( '--visible-items', parseInt( newValue ) ); break;
}
}
// #endregion
// #region UI BUILDING
#mkOption( value, label, sel=false ){
const frag = document.createRange().createContextualFragment(`
<div role="option" aria-selected="${sel}" tabindex="-1" class="option" data-value="${value}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<rect x="1" y="1" width="12" height="12" rx="2" fill="#888888" stroke="#888888" stroke-width="1.5"/>
<polyline points="3,7 6,10 11,4" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>${label}</span>
</div>
`);
return frag.firstElementChild;
}
// #endregion
// #region GETTERS / SETTERS
get value(){
const nodes = this.#listbox.querySelectorAll('div.option[aria-selected="true"]');
return Array.from( nodes ).map( el => el.dataset.value );
}
set value( ary ){
for( const opt of this.#listbox.querySelectorAll('.option') ){
opt.setAttribute( 'aria-selected', ary.includes( opt.dataset.value ) ? 'true' : 'false' );
}
}
// #endregion
// #region METHODS
loadArray( ary ){
for( const i of ary ){
this.#listbox.appendChild( this.#mkOption( i, i ) );
}
return this;
}
selectAll(){
for( const i of this.#listbox.querySelectorAll( '.option' ) ){
i.setAttribute( 'aria-selected', 'true' );
}
return this;
}
selectNone(){
for( const i of this.#listbox.querySelectorAll( '.option' ) ){
i.setAttribute( 'aria-selected', 'false' );
}
return this;
}
// #endregion
// #region EVENT HANDLERS
#onPointerDown = (e) => {
const opt = e.target.closest('[role="option"]');
if( !opt ) return;
e.preventDefault();
e.stopPropagation();
// Toggle state
const isSel = opt.getAttribute( 'aria-selected' ) === 'true';
opt.setAttribute( 'aria-selected', !isSel );
this.dispatchEvent(new CustomEvent( 'change', {
bubbles : true,
cancelable : true,
detail: {
value : this.value,
target : this,
},
}));
}
// #endregion
}
window.customElements.define( 'multi-select-input', MultiSelectInput );
// Uses & styles a select input as its core
class MultiSelectInput extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'value' ];
#input; // Reference to the internal <select> element
constructor(){
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host{ display:flex; flex-direction:row; box-sizing: border-box; }
select{
flex : 1 1 auto;
min-width : 0px;
border : 1px solid #555;
outline : none;
}
option{
padding : 3px 6px 3px 24px;
cursor : pointer;
color : #333;
background-color : white;
user-select : none;
background-image : url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Crect x='1' y='1' width='12' height='12' rx='2' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat : no-repeat;
background-position : 5px center;
background-size : 14px 14px;
&:hover{ background-color : #e0e0e0; }
}
option:checked{
background-image : url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Crect x='1' y='1' width='12' height='12' rx='2' fill='%23888888' stroke='%23888888' stroke-width='1.5'/%3E%3Cpolyline points='3,7 6,10 11,4' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
</style>
<select multiple size="4"></select>
`;
this.#input = this.shadowRoot.querySelector('select');
this.#input.addEventListener( 'pointerdown', this.#onPointerDown );
}
// #endregion
// #region BASE OVERRIDES
attributeChangedCallback( name, oldValue, newValue ){
switch( name ){
case 'value':{
try{
const ary = JSON.parse( newValue );
if( Array.isArray(ary) ) this.value = ary;
}catch(e){
console.error( 'MultiSelectInput: invalid value attribute', newValue );
}
break;
}
}
}
// #endregion
// #region GETTERS / SETTERS
get value(){
return Array.from( this.#input.selectedOptions ).map( o => o.value );
}
set value( v ){
const set = new Set( v.map( String ) );
for( const opt of this.#input.options ){
opt.selected = set.has( opt.value );
}
}
// Replace all options. Accepts array of { value, label } objects or plain strings.
// set options( items ){
// this.#select.innerHTML = '';
// for( const item of items ){
// const opt = document.createElement('option');
// opt.value = typeof item === 'object' ? String( item.value ) : String( item );
// opt.textContent = typeof item === 'object' ? item.label : String( item );
// this.#select.appendChild( opt );
// }
// // Resize to fit all items (no scrollbar unless host constrains height)
// this.#select.size = Math.max( 1, this.#select.options.length );
// }
// #endregion
// #region METHODS
loadArray( ary ){
for( const i of ary ){
this.#input.appendChild( new Option( i, i ) );
}
return this;
}
selectAll(){
for( const opt of this.#input.options ){
opt.selected = true;
}
return this;
}
// #endregion
// #region EVENT HANDLERS
// Toggle selection on click without requiring Ctrl/Shift — prevent default browser behavior
#onPointerDown = e => {
e.preventDefault();
e.stopPropagation();
const opt = e.target.closest('option');
if( !opt ) return;
opt.selected = !opt.selected;
this.dispatchEvent( new CustomEvent( 'change', { detail:{
target : this,
value : this.value,
} } ) );
// Restore scroll after a "microtick" to stop scroll jump
const scrollTop = this.#input.scrollTop;
setTimeout(()=>{ this.#input.scrollTop = scrollTop }, 0);
}
// #endregion
}
window.customElements.define( 'multi-select-input', MultiSelectInput );
class NumberInput extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'default', 'value', 'mode', 'min', 'max', 'step', 'fixed' ];
#inp;
#btnUp;
#btnDown;
#mode = 'float'; // How to handle Input/Display Data : [ int, float ]
#timeStep = null; // For holding the interval ID when incrementing/decrementing
#defaultValue = null; // If set, shows a reset button and allows resetting to this value
#fixedSize = 0; // Number of decimal places to show when displaying the value
#minValue = null; // Optional minimum value
#maxValue = null; // Optional maximum value
#step = 1; // Amount to increment/decrement when using the buttons
constructor(){
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host{
display : flex;
flex-direction : row;
align-items : stretch;
overflow : hidden;
}
input{
flex: 1 1 auto;
min-width: 0px;
border: 1px solid #555;
border-radius: 0px;
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance : none;
margin : 0;
}
}
button{
flex : 0 0 auto;
border : 1px solid #555;
user-select : none;
padding : 0px 2px;
color : #777;
cursor : pointer;
&:hover{ color: black; }
}
</style>
<button type="button" name="btnReset" title="Reset" style="border-right-width: 0px; display:none;">↺</button>
<input type="number" name="numInput" value="0">
<button type="button" name="btnUp" title="Increase" style="border-left-width: 0px;">▲</button>
<button type="button" name="btnDown" title="Decrease" style="border-left-width: 0px;">▼</button>
`;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
this.#inp = this.shadowRoot.querySelector('input[name="numInput"]');
this.#inp.addEventListener( 'input', this.#onInput );
this.#inp.addEventListener( 'change', this.#onChange );
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Increment Buttons
this.#btnUp = this.shadowRoot.querySelector('button[name="btnUp"]');
this.#btnDown = this.shadowRoot.querySelector('button[name="btnDown"]');
this.#btnUp.addEventListener( 'pointerdown', e=>{
this.#btnUp.setPointerCapture( e.pointerId );
this.#stepValue( 1 );
this.#timeStep = setInterval( ()=>this.#stepValue( 1 ), 100 );
});
this.#btnUp.addEventListener( 'pointerup', e=>{
this.#btnUp.releasePointerCapture( e.pointerId );
clearInterval(this.#timeStep);
this.#emit( 'input' ).#emit( 'change' );
});
this.#btnDown.addEventListener( 'pointerdown', e=>{
this.#btnDown.setPointerCapture( e.pointerId );
this.#stepValue( -1 );
this.#timeStep = setInterval( ()=>this.#stepValue( -1 ), 100 );
});
this.#btnDown.addEventListener( 'pointerup', e=>{
this.#btnDown.releasePointerCapture( e.pointerId );
clearInterval(this.#timeStep);
this.#emit( 'input' ).#emit( 'change' );
});
}
// #endregion
// #region BASE OVERRIDES
attributeChangedCallback( name, oldValue, newValue ){
// console.log( '----Attribute Changed:', name, 'New Value:', newValue );
switch( name ){
case 'value' : this.value = newValue; break;
case 'mode' : this.#mode = ( newValue === 'int' )? 'int' : 'float'; break;
case 'fixed' : this.#fixedSize = parseInt( newValue ); break;
case 'min':
this.#minValue = Number(newValue);
this.#inp.setAttribute( 'min', newValue );
break;
case 'max':
this.#maxValue = Number(newValue);
this.#inp.setAttribute( 'max', newValue );
break;
case 'step':
this.#step = Number(newValue);
this.#inp.setAttribute( 'step', newValue );
break;
case 'default':{
this.#defaultValue = this.#parseValue( newValue );
this.value = this.#defaultValue;
// Enable Reset Button
const btn = this.shadowRoot.querySelector('button[name="btnReset"]');
btn.addEventListener( 'click', this.#onReset );
btn.style.display = 'inline-block';
break; }
}
}
// #endregion
// #region GETTERS / SETTERS
get value(){ return this.#parseValue( this.#inp.value ); }
set value( v ){ this.#inp.value = this.#parseValue( v ); }
// #endregion
// #region HELPERS
#parseValue( v ){
let n = Number( v ); // Convert string/float/int to a number
if( Number.isNaN( n ) ) n = 0; // Incase something strange was passed in
if( this.#fixedSize > 0 ) n = parseFloat( n.toFixed( this.#fixedSize ) );
if( this.#mode === 'int' ) n = Math.trunc( n ); // Remove the decimal part
n = this.#clamp( n );
return n;
}
#clamp( n ){
if( this.#minValue !== null ) n = Math.max( this.#minValue, n );
if( this.#maxValue !== null ) n = Math.min( this.#maxValue, n );
return n;
}
#stepValue( dir ){ // dir: +1 up, -1 down
let n = this.#clamp( this.value + ( this.#step * dir ) );
if( this.#fixedSize > 0 ) n = parseFloat( n.toFixed( this.#fixedSize ) );
this.#inp.value = n;
return this;
}
#emit( type ){
this.dispatchEvent( new CustomEvent( type, {
bubbles : true,
cancelable : true,
detail : {
target : this,
value : this.value,
},
} ) );
return this;
}
// #endregion
// #region EVENT HANDLERS
#onInput = e=>{
e.preventDefault();
e.stopPropagation();
this.#emit( 'input' );
}
#onChange = e=>{
e.preventDefault();
e.stopPropagation();
this.#inp.value = this.#parseValue( this.#inp.value );
this.#emit( 'change' );
}
#onReset = ()=>{
this.value = this.#defaultValue;
this.#emit( 'input' ).#emit( 'change' );
}
// #endregion
}
window.customElements.define( 'number-input', NumberInput );
class ToggleBtn extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'value', 'checked', 'label', 'labeloff', 'disabled' ];
#elmTrack = null;
#elmLabel = null;
#value = false;
#disabled = false;
#label = '';
#labeloff = '';
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Bright Green color: #22c55e, bright cyan : #22c5b0
this.shadowRoot.innerHTML = `
<style>
:host {
display : grid;
grid-template-columns : 1fr;
grid-template-rows : 1fr;
user-select : none;
cursor : pointer;
min-width : 80px;
--height : 1.6rem;
--knob-size : calc(var(--height) - 6px);
--knob-width : 10px;
--knob-inset : 3px;
--col-off : #202020;
--col-on : #1a9a89;
--col-on-glow : rgba( 34,197,94,.4 );
--txt-col : #e4e4e7;
--duration : 340ms;
--ease : cubic-bezier( .4, 0, .2, 1 );
}
.track{
display : grid;
position : relative;
height : var(--height);
overflow : hidden;
border-radius : 4px;
background : var(--col-off);
transition : background var(--duration) var(--ease);
pointer-events : none;
}
.track.disabled{ opacity: .38; cursor: not-allowed; }
.track.on{
background : var( --col-on );
/* box-shadow : 0 0 14px 2px var(--col-on-glow); GLOW EFFECT */
}
.track.on .knob{
left : calc( 100% - var(--knob-width) - var(--knob-inset) );
background : #e0e0e0;
}
.label{
/* position : absolute;
inset : 0; */
display : flex;
align-items : center;
justify-content : center;
padding : 0px calc( var(--knob-width) + var(--knob-inset) * 2.5);
font-family : monospace;
font-size : 1.0rem;
font-weight : 400;
letter-spacing : .06em;
color : var(--txt-col);
white-space : nowrap;
transition : opacity var(--duration) var(--ease);
}
.knob{
position : absolute;
top : var(--knob-inset);
left : var(--knob-inset);
width : var(--knob-width);
height : var(--knob-size);
border-radius : 3px;
background : #606060;
transition : left var(--duration) var(--ease);
will-change : left;
}
</style>
<div class="track" part="track">
<div class="knob" part="knob"></div>
<span class="label" part="label"></span>
</div>
`;
this.#elmTrack = this.shadowRoot.querySelector( '.track' );
this.#elmLabel = this.shadowRoot.querySelector( '.label' );
this.addEventListener( 'click', this.#toggle );
}
// #endregion
// #region LIFECYCLE
attributeChangedCallback( name, oldVal, newVal ){
switch( name ){
case 'checked' :
case 'value' : this.value = newVal !== null; break;
case 'disabled' : this.disabled = newVal !== null; break;
case 'labeloff' : this.labeloff = newVal; break;
case 'label' : this.label = newVal; break;
}
}
// #endregion
// #region PRIVATE
#toggle = ()=>{
if( this.#disabled ) return;
this.value = !this.#value;
this.dispatchEvent( new CustomEvent( 'change', {
bubbles : true,
composed : true,
cancelable : true,
detail : { value: this.#value, target: this, },
}));
}
#updateLabel(){
if( this.#labeloff ){
this.#elmLabel.textContent = this.#value ? this.#label : this.#labeloff;
return true;
}
return false;
}
// #endregion
// #region GETTERS / SETTERS
get value(){ return this.#value; }
set value( v ){
this.#value = v;
this.#elmTrack.classList.toggle( 'on', this.#value );
this.#updateLabel();
}
get disabled(){ return this.#disabled; }
set disabled( v ){
this.#disabled = v;
this.#elmTrack.classList.toggle( 'disabled', this.#disabled );
}
set labeloff( v ){ this.#labeloff = v; this.#updateLabel(); }
set label( v ){
this.#label = v;
if( !this.#updateLabel() ) this.#elmLabel.textContent = v;
}
// #endregion
}
customElements.define( 'toggle-btn', ToggleBtn );
class Vec3Input extends HTMLElement{
// #region MAIN
static observedAttributes = [ 'value', 'mode', 'fixed' ];
#mode = ""; // How to handle Input/Display Data : [ radian ]
#fixedSize = 0; // Crop decimal places when displaying data
#value = [0,0,0]; // internal value state
#inpX; // References to the 3 input fields
#inpY;
#inpZ;
constructor(){
super();
this.attachShadow({ mode: 'open' });
// Build UI
this.shadowRoot.innerHTML = `
<style>
:host{ display:flex; flex-direction:row; gap:5px; overflow:hidden; }
input{ flex:1 1 auto; min-width:0px; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] { -moz-appearance: textfield; }
</style>
<input type="number" name="inpX" data-idx="0" value="0">
<input type="number" name="inpY" data-idx="1" value="0">
<input type="number" name="inpZ" data-idx="2" value="0">
`;
this.#inpX = this.shadowRoot.querySelector('input[name="inpX"]');
this.#inpY = this.shadowRoot.querySelector('input[name="inpY"]');
this.#inpZ = this.shadowRoot.querySelector('input[name="inpZ"]');
// Bind Events
this.#inpX.addEventListener( 'input', this.onHandler );
this.#inpY.addEventListener( 'input', this.onHandler );
this.#inpZ.addEventListener( 'input', this.onHandler );
this.#inpX.addEventListener( 'change', this.onHandler );
this.#inpY.addEventListener( 'change', this.onHandler );
this.#inpZ.addEventListener( 'change', this.onHandler );
}
// #endregion
// #region BASE OVERRIDES
// Handle changes to element attributes
attributeChangedCallback( name, oldValue, newValue ){
switch( name ){
case 'mode' : this.#mode = newValue; break;
case 'fixed' : this.#fixedSize = parseInt( newValue ); break;
case 'value' :{
try{
const ary = JSON.parse( newValue );
if( !Array.isArray(ary) || ary.length !== 3 ) throw new Error( 'Invalid value for Vec3Input. Must be a JSON array of 3 numbers.');
this.value = ary;
}catch(e){
console.error( 'Invalid value for Vec3Input. Must be a JSON array of 3 numbers.', newValue );
}
break;
}
}
}
// #endregion
// #region GETTERS / SETTERS
get value(){
let [x,y,z] = this.#value;
switch( this.#mode ){
// Display data is in degrees, switch to radians for output
case 'radian':
x = x * ( Math.PI / 180 );
y = y * ( Math.PI / 180 );
z = z * ( Math.PI / 180 );
break;
}
return [x,y,z];
}
set value( v ){
// TODO - Include also handling Objects with x,y,z properties for flexible input
if( !Array.isArray(v) || v.length !== 3 ) throw new Error( 'Value must be an array of 3 numbers.' );
let [x,y,z] = v;
switch( this.#mode ){
// Input data is in radians, switch to degrees for display
case 'radian':
x = x * ( 180 / Math.PI );
y = y * ( 180 / Math.PI );
z = z * ( 180 / Math.PI );
break;
}
if( this.#fixedSize > 0 ){
x = parseFloat( x.toFixed( this.#fixedSize ) );
y = parseFloat( y.toFixed( this.#fixedSize ) );
z = parseFloat( z.toFixed( this.#fixedSize ) );
}
this.#inpX.value = this.#value[0] = x;
this.#inpY.value = this.#value[1] = y;
this.#inpZ.value = this.#value[2] = z;
}
// #endregion
// #region EVENT HANDLERS
onHandler = e =>{
e.preventDefault();
e.stopPropagation();
// Save change to internal value
const idx = parseInt( e.target.dataset.idx );
const v = parseFloat( e.target.value );
this.#value[idx] = isNaN(v)? 0 : v; // handle empty or invalid input as 0
// Emit event with updated vector data
this.dispatchEvent( new CustomEvent( e.type, {
bubbles : true,
cancelable : true,
detail : {
value : this.value,
target : this,
},
} ) );
}
// #endregion
}
window.customElements.define( 'vec3-input', Vec3Input );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment