<wc-rotation-input precision="0" unit="deg" trigger="settled">
		<input type="number" id="value">
	</wc-rotation-input>
	<div id="grad" style="height:100px; width:100px;"></div>
	<script src="wc-rotation-input.js" type="module"></script>
function fireEvent(element, eventName, data, bubbles = true, cancelable = true) {
    const event = document.createEvent("HTMLEvents");
    event.initEvent(eventName, bubbles, cancelable);
    if (data) {
        event.data = data;
    }
    return element.dispatchEvent(event);
}

function validateEnum(val, choices){
    if(choices.includes(val)){
        return val;
    }
    throw new Error(`invalid type, only ${choices.join(",")} allowed.`);
}

class WcRotationInput extends HTMLElement {
    #isManipulating = false;
    #center = {};
    #precision = 2;
    #unit = "deg";
    #currentValue = 0;
    static #unitType = ["deg", "rad"];
    #trigger = "manipulate";
    static #triggerType = ["manipulate", "settled"];
    static observedAttributes = ["precision", "unit", "trigger"];
    constructor() {
        super();
        this.bind(this);
    }
    bind(element){
        this.render = this.render.bind(element);
        this.cacheDom = this.cacheDom.bind(element);
        this.attachEvents = this.attachEvents.bind(element);
        this.onPointerDown = this.onPointerDown.bind(element);
        this.onPointerMove = this.onPointerMove.bind(element);
        this.onPointerUp = this.onPointerUp.bind(element);
    }
    render(){
        this.shadow = this.attachShadow({ mode: "open" });

        this.shadow.innerHTML = `
            <style>
                :host {
                    display: inline-flex;
                    flex-flow: row nowrap;
                    gap: 0.5rem;
                    align-items: center;
                    width: 8rem;
                    height: 2rem;
                    --half-stroke: calc(var(--stroke-width, 1px) / 2);
                }
                svg {
                    width: auto;
                    height: 100%;
                }
                circle {
                    r : calc(50% - var(--half-stroke));
                    cx : 50%;
                    cy : 50%;
                    fill: var(--fill-color, #fff);
                    stroke-width: var(--stoke-width, 1px);
                    stroke: var(--stroke-color, #000);
                }
                #pointer {
                    stroke-width: var(--stoke-width, 1px);
                    stroke: var(--stroke-color, #000);
                    transform-origin: center center;
                }
                #value {
                    user-select: none;
                }
            </style>
            <svg viewBox="0 0 16 16">
                <circle />
                <line x1="50%" y1="50%" x2="100%" y2="50%" id="pointer"/>
            </svg>
            <div id="value"></div>
        `;
    }
    connectedCallback() {
        this.render();
        this.cacheDom();
        this.attachEvents();
    }
    cacheDom(){
        this.dom = {
            input: this.querySelector("input"),
            pointer: this.shadow.querySelector("#pointer"),
            value: this.shadow.querySelector("#value"),
            svg: this.shadow.querySelector("svg")
        };
    }
    attachEvents(){
        this.dom.svg.addEventListener("pointerdown", this.onPointerDown);
    }
    onPointerDown(e){
        this.#isManipulating = true;
        const rect = this.dom.svg.getBoundingClientRect();
        this.#center = { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
        document.addEventListener("pointermove", this.onPointerMove);
        document.addEventListener("pointerup", this.onPointerUp);
    }
    onPointerMove(e){
        const offsetX = e.clientX - this.#center.x;
        const offsetY = this.#center.y - e.clientY;  //y-coords flipped
        let rad;
        if (offsetX >= 0 && offsetY >= 0){ rad = Math.atan(offsetY / offsetX); }
        else if (offsetX < 0 && offsetY >= 0) { rad = (Math.PI / 2) + Math.atan(-offsetX / offsetY); }
        else if (offsetX < 0 && offsetY < 0) { rad = Math.PI + Math.atan(offsetY / offsetX); }
        else { rad = (3 * Math.PI / 2) + Math.atan(offsetX / -offsetY); }
        const deg = (180 / Math.PI) * rad;
        const finalValue = (this.#unit === "rad" ? rad : deg).toFixed(this.#precision);
        this.dom.pointer.style = `transform: rotateZ(-${deg}deg)`;
        this.dom.value.textContent = finalValue;

        if(this.#trigger === "manipulate"){
            this.dom.input.value = finalValue;
            fireEvent(this.dom.input, "input");
            fireEvent(this.dom.input, "change");
        } else {
            this.#currentValue = finalValue;
        }
    }
    onPointerUp(){
        this.#isManipulating = false;
        document.removeEventListener("pointermove", this.onPointerMove);
        document.removeEventListener("pointerup", this.onPointerUp);
        if(this.#trigger === "settled"){
            this.dom.input.value = this.#currentValue;
            fireEvent(this.dom.input, "input");
            fireEvent(this.dom.input, "change");
        }
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this[name] = newValue;
    }
    set precision(val){
        this.#precision = parseInt(val);
    }
    set unit(val) {
        this.#unit = validateEnum(val, WcRotationInput.#unitType);
    }
    set trigger(val) {
        this.#trigger = validateEnum(val, WcRotationInput.#triggerType);
    }
}

customElements.define("wc-rotation-input", WcRotationInput);



//demo page code

const input = document.querySelector("#value");
const grad = document.querySelector("#grad");

input.addEventListener("input", e => {
grad.style.background =  `conic-gradient(red ${input.value}deg, white ${parseInt(input.value) + 10}deg)`;});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.