Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer
Created
July 10, 2021 03:20
-
-
Save dromer/13ab8abef94303ac503a6d74e8ae19ef to your computer and use it in GitHub Desktop.
Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer
This file contains 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
<form class="knob-group" lang="en" novalidate spellcheck="false"> | |
<legend data-key="space / return">Knob Group</legend><hr/> | |
<fieldset class="knob"> | |
<input type="number" id="k" value="0.5" min="0" max="1" step="0.0078125" placeholder="-" autocomplete="off" required/> | |
<label for="k" accesskey="k" data-unit="db">K Knob</label> | |
</fieldset> | |
<fieldset class="knob"> | |
<input type="number" id="l" value="6" min="0" max="12" step="0.1" placeholder="-" autocomplete="off" required/> | |
<label for="l" accesskey="l" data-unit="Amp">L Knob</label> | |
</fieldset> | |
<fieldset class="knob"> | |
<input type="number" id="m" value="0" min="0" max="100" step="1" placeholder="-" autocomplete="off" required/> | |
<label for="m" accesskey="m" data-unit="cm">M Knob</label> | |
</fieldset> | |
</form> | |
<!-- | |
<form class="knob-group" lang="en" novalidate spellcheck="false"> | |
<legend data-key="space / return">Knobs Balance</legend><hr/> | |
<fieldset class="knob knob-balance"> | |
<input type="number" id="o" value="-5" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/> | |
<label for="o" accesskey="o">O Knob</label> | |
<i data-min="-12" data-max="12" aria-hidden="true"></i> | |
</fieldset> | |
<fieldset class="knob knob-balance"> | |
<input type="number" id="p" value="5" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/> | |
<label for="p" accesskey="p">P Knob</label> | |
<i data-min="-12" data-max="12" aria-hidden="true"></i> | |
</fieldset> | |
<fieldset class="knob knob-balance"> | |
<input type="number" id="q" value="0" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/> | |
<label for="q" accesskey="q">Q Knob</label> | |
<i data-min="-12" data-max="12" aria-hidden="true"></i> | |
</fieldset> | |
</form> | |
<form class="knob-group" lang="en" novalidate spellcheck="false"> | |
<legend data-key="space / return">Small Knobs</legend><hr/> | |
<fieldset class="knob knob-small"> | |
<input type="number" id="r" value="8" min="0" max="12" step="0.2" placeholder="-" autocomplete="off" required/> | |
<label for="r" accesskey="r" data-unit="db">R Knob</label> | |
</fieldset> | |
<fieldset class="knob knob-small"> | |
<input type="number" id="s" value="4" min="0" max="12" step="0.2" placeholder="-" autocomplete="off" required/> | |
<label for="s" accesskey="s" data-unit="cm">S Knob</label> | |
</fieldset> | |
<fieldset class="knob knob-small"> | |
<input type="number" id="t" value="0" min="-10" max="10" step="0.2" placeholder="-" autocomplete="off" required/> | |
<label for="t" accesskey="t">T Knob</label> | |
<i data-min="-10" data-max="10" aria-hidden="true"></i> | |
</fieldset> | |
<fieldset class="knob knob-small"> | |
<input type="number" id="u" value="0" min="-10" max="10" step="0.2" placeholder="-" autocomplete="off" required/> | |
<label for="u" accesskey="u">U Knob</label> | |
<i data-min="-10" data-max="10" aria-hidden="true"></i> | |
</fieldset> | |
</form> --> | |
<style> | |
/* Presentation */ | |
html{ | |
height: 100%; | |
font-size: 1rem; | |
} | |
body{ | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
margin: auto; | |
justify-content: center; | |
align-items: center; | |
background-color: #111; | |
font-family: Arial, Helvetica, sans-serif; | |
color: #fff; | |
user-select: none; | |
touch-action: none; | |
} | |
form { margin: 0 0 1em 0; } | |
form:last-of-type { margin-bottom: 0; } | |
</style> |
This file contains 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
// Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer | |
// Created: 2020.10.11, 10:20h | |
; (function (W, D){ | |
var ks = D.querySelectorAll('.knob input'), | |
keys = { left:37, right:39, add:107, sub:109, home:36 , end:35, space:32, return:13, esc:27 }, | |
path = '<path d="M20,76 A 40 40 0 1 1 80 76"/>', // 184 svg units for full stroke | |
curY = 0, moving = false, hasPE = W.PointerEvent; | |
[].forEach.call(ks, function (k){ knob.call(k); }); | |
function knob () { | |
var k = this, id = k.id || k.name, | |
fls = k.parentElement, | |
lbl = fls.querySelector('[for="'+id+'"]'), | |
min = k.min ? parseFloat(k.min) : 0, | |
max = k.max ? parseFloat(k.max) : 100, | |
dif = Math.abs(min) + Math.abs(max), | |
stp = k.step ? parseFloat(k.step) : dif/10, | |
val = k.value ? parseFloat(k.value) : dif/2, | |
ind = fls.querySelector('svg path:last-of-type'), | |
frm = k.form ? k.form : fls.parentElement, | |
lgd = k.form.querySelector('legend'), | |
bal = Math.abs(min)-Math.abs(max) === 0 || fls.className.match('knob-balance'); | |
// Fix missin properties, svg indicator & decimal | |
// separator ( ',' to '.' -> ua lang dependant) ? | |
frm.lang = 'en'; k.value = val; k.step = stp; | |
k.setAttribute('autocomplete','off'); | |
if(bal && !fls.className.match('knob-balance')) fls.className += ' knob-balance'; | |
if(lbl) lbl.onclick = function(e){ e.preventDefault(); }; | |
if(!ind) ind = svg(); | |
if(lgd) { | |
lgd.setAttribute('tabindex', 0); | |
lgd.onclick = function() { toggleGroup(frm); }; | |
lgd.onkeydown = legendkeys; | |
} | |
// Event listener | |
k.addEventListener('input', input, false); | |
k.onkeydown = knobkeys; | |
fls.ondblclick = dblclick; | |
fls.addEventListener('wheel', wheel, false); // No attribute, because IE | |
hasPE ? fls.onpointerdown = start : fls.onmousedown = start; // Overwrite | |
ind.onclick = click; | |
ind.previousElementSibling.onclick = click; | |
input(); | |
// Private methods | |
function input () { | |
val = k.value.trim(); | |
if(val > max) k.value = max; | |
else if(val < min) k.value = min; | |
else if(val === '') k.value = min; | |
var per = (k.value/dif)*100, | |
deg = 0; | |
if(bal) { // Balance number input | |
deg = per*1.32*2; | |
var len = Math.abs(per)*1.84; | |
var drr = per > 0 ? ('87 10 0 '+len+' '+(87-len)) : ((87-len)+' '+len+' 0 10 87'); | |
ind.style.setProperty('stroke-dasharray', drr); | |
fls.style.setProperty('--knob-deg', deg); | |
} | |
else { // Normal number input | |
if (per >= 0 && per <= 100 && per != 50) deg = per*1.32*2 - 132; | |
ind.style.setProperty('stroke-dashoffset', -per*1.84 +'%'); | |
fls.style.setProperty('--knob-deg', deg); | |
} | |
} | |
function click (e) { | |
if(k.disabled || k.readonly) return; | |
var b = this.parentElement.getBoundingClientRect(), | |
c = { x: b.width/2, y: b.height/2 }, | |
p2 = { x: e.pageX - b.left, y: e.pageY - b.top }, | |
p1 = { x: 0, y: b.height }; // stroke-width 8 of path ? | |
var rad = angle (p1, c, p2) ; | |
var deg = rad * (180/Math.PI); | |
if(p2.x > b.width/2 && deg < 180) deg = 360 - deg; | |
// console.log(parseInt(deg,10) +'°', (dif/270)*deg); | |
k.value = parseInt((dif/270)*deg); | |
k.dispatchEvent(new Event('input')); | |
} | |
function dblclick (e) { | |
if(k.disabled || k.readonly) return; | |
var cache = k.hasAttribute('data-cache'); | |
if(cache) { k.value = k.getAttribute('data-cache'); k.removeAttribute('data-cache'); } | |
else { k.setAttribute('data-cache', k.value); k.value = bal ? 0 : k.defaultValue; } | |
k.dispatchEvent(new Event('input')); | |
} | |
function start (e) { | |
if(k.disabled || k.readonly) return; | |
moving = true; curY = e.pageY; | |
D.addEventListener(hasPE ? 'pointermove' : 'mousemove', move, false); | |
D.addEventListener(hasPE ? 'pointerup' : 'mouseup', end, false); | |
} | |
function move (e) { | |
if(e.pageY - curY !== 0) { | |
(e.pageY - curY) > 0 ? k.stepUp() : k.stepDown(); | |
k.dispatchEvent(new Event('input')); | |
} | |
curY = e.pageY; | |
} | |
function end (e) { | |
moving = false; curY = 0; | |
D.removeEventListener(hasPE ? 'pointermove' : 'mousemove', move, false); | |
D.removeEventListener(hasPE ? 'pointerup' : 'mouseup', end, false); | |
k.select(); | |
} | |
function wheel (e) { | |
var delta = e.deltaY; | |
if(delta !== 0) { | |
delta < 0 ? k.stepUp() : k.stepDown(); | |
k.dispatchEvent(new Event('input')); | |
} | |
} | |
function knobkeys (e) { | |
if(this !== D.activeElement) return; | |
var c = e.keyCode ? e.keyCode : e.which; | |
if (c === keys.left) { k.stepDown(); } | |
else if (c === keys.right) { k.stepUp(); } | |
else if (c === keys.end) { k.value = min; } | |
else if (c === keys.home) { k.value = max; } | |
else if (c === keys.add) { k.stepUp(); } | |
else if (c === keys.sub) { k.stepDown(); } | |
else if (c === keys.esc && lgd) { lgd.focus(); } | |
k.dispatchEvent(new Event('input')); | |
} | |
function legendkeys (e) { | |
if(this !== D.activeElement) return; | |
var c = e.keyCode ? e.keyCode : e.which; | |
if(c === keys.space) toggleGroup(frm); | |
else if(c === keys.return) toggleGroup(frm); | |
}; | |
function svg () { | |
var s = D.createElementNS('http://www.w3.org/2000/svg','svg'); | |
s.setAttribute('viewBox','0 0 100 100'); s.setAttribute('aria-hidden', true); | |
s.innerHTML = path + path; fls.appendChild(s); | |
return s.querySelector('path:last-of-type'); | |
} | |
function angle (p1, c, p2) { // Point 1, circle center point, point 2 | |
var p1c = Math.sqrt(Math.pow(c.x-p1.x, 2)+ Math.pow(c.y-p1.y, 2)); | |
var cp2 = Math.sqrt(Math.pow(c.x-p2.x, 2)+ Math.pow(c.y-p2.y, 2)); | |
var p1p2 = Math.sqrt(Math.pow(p2.x-p1.x, 2)+ Math.pow(p2.y-p1.y, 2)); | |
return Math.acos((cp2*cp2 + p1c*p1c - p1p2*p1p2)/(2*cp2*p1c)) ; | |
} | |
function toggleGroup (f) { | |
var s = f.hasAttribute('data-status') ? f.getAttribute('data-status') : false, | |
isD = s ? s.match('disabled') : false, | |
isR = s ? s.match('readonly') : false; | |
isD ? f.removeAttribute('data-status') : f.setAttribute('data-status', 'disabled'); | |
[].forEach.call(frm.querySelectorAll('.knob input'), function (i){ | |
if(isD) { i.removeAttribute('disabled'); i.required = true; i.draggable = true;} | |
else { i.disabled = true; i.removeAttribute('required'); i.removeAttribute('draggable'); } | |
if(isD) { i.removeAttribute('readonly'); i.required = true; } | |
else { i.setAttribute('readonly',''); i.removeAttribute('required'); } | |
}); | |
} | |
} | |
})(window, document); |
This file contains 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
// Theming | |
$primary: magenta !default; | |
$secondary: cyan !default; | |
$white: #FFF !default; | |
$black: #000 !default; | |
$dark: #2c2d2f !default; | |
$gray: gray !default; | |
// Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer | |
$knob-d: 4em !default; | |
$knob-c: gray !default; | |
$knob-spacing: .5em !default; | |
$knob-border-c: #181b1c !default; | |
$knob-border-w: .5em !default; | |
$knob-bg-c: $dark !default; | |
$knob-ind-c: #888 !default; | |
$knob-ind-focus-c: magenta !default; | |
$knob-label-c: #e4e8ea !default; | |
$knob-group-border-r: .2rem !default; | |
$knob-group-bg-c: $dark !default; | |
$knob-font: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace !default; | |
// Box model reset | |
*, *::before, *::after { box-sizing: border-box; } | |
// Light / dark mode (light is default) | |
[data-mode="dark"] { | |
.knob-group { | |
} | |
.knob { | |
} | |
} | |
// Knobs group container (i.e. a form) | |
.knob-group { | |
position: relative; | |
display: inline-block; | |
background-color: $knob-group-bg-c; | |
padding: 0 $knob-spacing; | |
border-radius: $knob-group-border-r; | |
font-family: $knob-font; | |
legend { | |
position: relative; | |
display: block; | |
width: auto; | |
line-height: 1.5; | |
text-align: left; | |
padding: $knob-spacing $knob-spacing $knob-spacing/2 $knob-spacing; | |
white-space: nowrap; | |
background-color: $knob-group-bg-c; | |
z-index: 2; | |
outline: 0; | |
&:focus { | |
&::before { box-shadow: 0 0 0 .2em rgba($knob-ind-focus-c, .25); } | |
} | |
&:focus-visible { | |
&::after { content: attr(data-key); } | |
} | |
&::before { | |
content: ''; | |
position: relative; | |
display: inline-block; | |
height: $knob-spacing; | |
width: $knob-spacing; | |
float: left; | |
border-radius: 100%; | |
background-color: $knob-ind-focus-c; | |
cursor: pointer; | |
transition: background .2s linear; | |
margin: $knob-spacing ($knob-spacing*2) 0 (-$knob-spacing/2); | |
} | |
&::after { | |
position: absolute; | |
right: $knob-spacing/4; top: $knob-spacing*1.25; | |
font-size:.5em; | |
padding: .5em 1em; | |
background-color: $knob-border-c; | |
font-weight: 600; | |
} | |
} | |
hr { | |
position: absolute; | |
width: calc(100% - #{$knob-spacing*2}); | |
display: inline-block; | |
margin: 0; padding: 0; | |
top: $knob-spacing*2.5; right: $knob-spacing; | |
border: 0; | |
border-top: 1px solid currentColor; | |
z-index: 1; | |
} | |
&[data-status="disabled"] { | |
// color: $knob-ind-c; | |
legend { | |
color: currentColor !important; | |
&::before { background-color: currentColor; } | |
&:focus::before { box-shadow: 0 0 12px 1px currentColor;} | |
} | |
.knob { | |
svg path:first-of-type { stroke: currentColor; } | |
} | |
} | |
} | |
// Knob container (i.e. a fieldset) | |
.knob { | |
--knob-deg: 0; | |
display: inline-block; | |
position: relative; | |
padding:0; margin:0; | |
padding-bottom: 2em; | |
width: $knob-d; | |
border: 0; | |
text-align: center; | |
touch-action: none; | |
font-size: 1rem; // Knob sizing | |
&.knob-small { font-size: .72rem; } | |
&.knob-balance { | |
hr, i { | |
position: absolute; | |
top: $knob-d/.65 - 1em; left: 0; | |
width: 100%; | |
border: 0; | |
font-size: .65em; | |
font-style: normal; | |
line-height: 2.5; | |
color: $knob-ind-c; | |
&::before, &::after { position: absolute; } | |
&::before { content: attr(data-min); left: 0; } | |
&::after { content: attr(data-max); right: 0; } | |
} | |
svg path { | |
stroke-dasharray: 87 10 87; | |
&:last-of-type { stroke-dashoffset: 0; } | |
} | |
} | |
// debug*, &, *::before, *::after { box-shadow: 0 0 0 1px rgba(255,255,255,.3); } | |
input { | |
appearance: textfield; | |
-moz-appearance: textfield; // if not autoprefixed | |
position: relative; | |
left: 0; top:0; | |
display: block; | |
width: $knob-d/.75; height: $knob-border-w*3; | |
font: inherit; | |
font-size: .75em; | |
line-height: 1; | |
color: currentColor; | |
text-align: center; | |
font-variant-numeric: tabular-nums lining-nums; | |
background-color: transparent; | |
border: 0; | |
margin: $knob-d/.75 + .5em 0 0 0; padding: 0; | |
outline: 0; | |
cursor: default; | |
z-index: 2; | |
caret-color: currentColor; | |
&:placeholder { opacity: 1; color: currentColor; } | |
&::-webkit-outer-spin-button, | |
&::-webkit-inner-spin-button { | |
-webkit-appearance: none; | |
margin: 0; | |
} | |
&::selection { color: currentColor; background-color: $knob-ind-focus-c; } | |
&[disabled], &[readonly] { | |
cursor: not-allowed; | |
& ~ *, & ~ *::before, & ~ *::after { | |
pointer-events: none; | |
} | |
&::selection { background-color: transparent; } | |
} | |
} | |
label { | |
position: absolute; | |
left: 0; top: 0; | |
display: inline-block; | |
width: 100%; height: 100%; | |
overflow: hidden; | |
padding-top: $knob-d + 2em; | |
font-size: 1em; | |
white-space: nowrap; | |
text-overflow: ellipsis; | |
z-index: 1; | |
pointer-events: none; | |
&::before, &::after { | |
position: absolute; | |
display: inline-block; | |
} | |
&::before { | |
content: ''; | |
left: 50%; top: $knob-border-w; | |
width: $knob-d - $knob-border-w; height: $knob-d - $knob-border-w ; | |
border: $knob-border-w solid $knob-border-c; | |
border-radius: 100%; | |
background-color: transparent; | |
background: linear-gradient(to bottom, currentColor 0% 100%) no-repeat 50% 0%; | |
background-size: .2em 1em; | |
transform-origin: center center; | |
transform: translateX(-50%) rotate(0deg); // Fallback | |
transform: translateX(-50%) rotate(calc(1deg * var(--knob-deg))); | |
cursor: default; | |
// cursor: n-resize; | |
pointer-events: fill; | |
} | |
&::after { | |
content: attr(data-unit); | |
top: $knob-d/.65 - 1.1em; right: 0; | |
font-size: .65em; | |
line-height: 2.5; | |
color: $knob-ind-c; | |
} | |
} | |
svg { | |
position: absolute; | |
left: 50%; top: 0; | |
width: $knob-d + $knob-border-w; height: $knob-d + $knob-border-w ; | |
transform: translateX(-50%); | |
stroke-dasharray: 184 184; | |
fill: none; | |
stroke: currentColor; | |
z-index: 3; | |
path { | |
// stroke-width: $knob-border-w; | |
stroke-width: 5; | |
stroke-dashoffset: 0; | |
visibility: visible; | |
pointer-events: stroke; | |
transition: all .2s cubic-bezier(0, 0, 0.2, 1); | |
&:first-of-type { stroke: $knob-ind-focus-c; } | |
&:last-of-type { stroke: $knob-ind-c; stroke-dashoffset: -97; } | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment