Last active
June 24, 2026 23:15
-
-
Save sketchpunk/57a473fc63cab47dc99ee225782ab819 to your computer and use it in GitHub Desktop.
Web Components
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
| 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 ); |
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
| /* | |
| 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 | |
| } |
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
| 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 ); |
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
| // 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 ); |
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
| 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 ); |
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
| 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 ); |
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
| 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