Skip to content

Commit 0aae018

Browse files
committed
feat: keyboard accessibility
1 parent 56aedd4 commit 0aae018

File tree

7 files changed

+209
-37
lines changed

7 files changed

+209
-37
lines changed

src/lib/ImageKnob.svelte

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
unit = '',
2424
onChange,
2525
value = $bindable(),
26+
step,
27+
acceleration,
28+
maxSpeed,
29+
initialDelay,
2630
defaultValue,
2731
param,
2832
stiffness = 0.5,
@@ -106,33 +110,58 @@
106110
</script>
107111

108112
<KnobBase
113+
{acceleration}
109114
{colors}
110115
{decimalDigits}
111116
{defaultValue}
112117
{disabled}
113118
{draggable}
114119
{label}
120+
{maxSpeed}
115121
{onChange}
116122
{param}
117123
{snapThreshold}
118124
{snapValues}
125+
{step}
119126
{style}
120127
{unit}
128+
{initialDelay}
121129
bind:value
122130
{rotationDegrees}
123131
>
124-
{#snippet ui({ handleTouchStart, handleMouseDown, handleDblClick })}
132+
{#snippet ui({
133+
handleTouchStart,
134+
handleMouseDown,
135+
handleDblClick,
136+
handleKeyDown,
137+
normalizedValue
138+
})}
125139
<canvas
140+
role="slider"
141+
tabindex="0"
142+
aria-valuenow={normalizedValue}
126143
bind:this={canvas}
127144
{width}
128145
{height}
129146
class={className}
130147
onmousedown={handleMouseDown}
131148
ontouchstart={handleTouchStart}
149+
onkeydown={handleKeyDown}
132150
ondblclick={handleDblClick}
133151
oncontextmenu={(e) => e.preventDefault()}
134152
draggable={false}
135153
>
136154
</canvas>
137155
{/snippet}
138156
</KnobBase>
157+
158+
<style>
159+
canvas {
160+
outline: none;
161+
}
162+
163+
canvas:active,
164+
canvas:focus {
165+
filter: drop-shadow(2px 0 2px currentColor);
166+
}
167+
</style>

src/lib/KnobBase.svelte

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,26 @@
77
handleTouchStart: (e: TouchEvent) => void;
88
handleMouseDown: ({ clientY }: MouseEvent) => void | boolean;
99
handleDblClick: () => void;
10+
handleKeyDown: (e: KeyboardEvent) => void;
1011
};
1112
13+
// TODO docs
1214
export type SharedKnobProps = {
1315
style?: string;
1416
disabled?: boolean;
1517
draggable?: boolean;
1618
onChange?: (value: number | string) => void;
1719
param: FloatParam | EnumParam<readonly string[]>;
1820
value: number | string;
21+
22+
/** normalized value from 0.0 to 1.0 */
23+
step?: number;
24+
/** multiplier for acceleration */
25+
acceleration?: number;
26+
maxSpeed?: number;
27+
/** initial delay before acceleration starts (ms) */
28+
initialDelay?: number;
29+
1930
defaultValue?: number | string;
2031
label?: string;
2132
unit?: string;
@@ -45,13 +56,17 @@
4556
unit = '',
4657
onChange,
4758
value = $bindable(),
59+
step = 0.01,
60+
acceleration = 1.4,
61+
maxSpeed = 0.2,
62+
initialDelay = 100,
4863
defaultValue,
4964
param,
5065
rotationDegrees,
5166
decimalDigits = 0,
5267
snapValues = [],
5368
snapThreshold = 0.1,
54-
disabled = false,
69+
disabled: isDisabled = false,
5570
draggable = true,
5671
colors = {}
5772
}: KnobBaseProps = $props();
@@ -105,11 +120,11 @@
105120
106121
function handleMouseMove({ clientY }: MouseEvent) {
107122
if (!draggable) return;
108-
if (disabled) return;
123+
if (isDisabled) return;
109124
if (!isDragging) return;
110125
const deltaY = startY - clientY;
111126
const deltaValue = deltaY / 200;
112-
setValue(clamp(startValue + deltaValue, 0, 1));
127+
setValue(startValue + deltaValue);
113128
114129
return true;
115130
}
@@ -125,12 +140,48 @@
125140
(param as EnumParam<string[]>).variants?.[0];
126141
if (val === undefined) return;
127142
128-
setValue(clamp(normalize(val, param), 0, 1));
143+
setValue(normalize(val, param));
129144
}
130145
131146
const handleTouchStart = toMobile(handleMouseDown);
132147
const handleTouchMove = toMobile(handleMouseMove);
133148
149+
type Direction = 'left' | 'right';
150+
151+
let intervalId = -1;
152+
let currentSpeed = step;
153+
154+
const directions: Record<string, Direction> = {
155+
ArrowLeft: 'left',
156+
ArrowDown: 'left',
157+
ArrowRight: 'right',
158+
ArrowUp: 'right'
159+
};
160+
161+
function adjustValue(direction: Direction) {
162+
const delta = direction === 'right' ? currentSpeed : -currentSpeed;
163+
console.log(direction);
164+
setValue(normalizedValue + delta);
165+
166+
currentSpeed = Math.min(maxSpeed, currentSpeed * acceleration);
167+
}
168+
169+
function handleKeyDown(e: KeyboardEvent) {
170+
if (isDisabled) return;
171+
if (!(e.key in directions)) return;
172+
if (intervalId > -1) return;
173+
174+
intervalId = window.setInterval(() => adjustValue(directions[e.key]), initialDelay);
175+
}
176+
177+
function handleKeyUp() {
178+
if (intervalId === -1) return;
179+
180+
window.clearInterval(intervalId);
181+
intervalId = -1;
182+
currentSpeed = step;
183+
}
184+
134185
$effect(() => {
135186
rotationDegrees.set(normalizedValue * 270 - 135);
136187
@@ -151,7 +202,7 @@
151202
return;
152203
}
153204
154-
let newValue = unnormalizeToNumber(newNormalizedValue, param);
205+
let newValue = unnormalizeToNumber(clamp(newNormalizedValue, 0, 1), param);
155206
156207
if (fixedSnapValues.length > 0) {
157208
const nearestSnapValue = fixedSnapValues.reduce((prev, curr) => {
@@ -176,14 +227,20 @@
176227
}
177228
</script>
178229

179-
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} ontouchend={handleMouseUp} />
230+
<svelte:window
231+
onmousemove={handleMouseMove}
232+
onmouseup={handleMouseUp}
233+
ontouchend={handleMouseUp}
234+
onkeyup={handleKeyUp}
235+
/>
180236

181237
<div class="container" {style}>
182238
{@render ui?.({
183239
normalizedValue,
184240
handleTouchStart,
185241
handleMouseDown,
186-
handleDblClick
242+
handleDblClick,
243+
handleKeyDown
187244
})}
188245
{#if label}
189246
<div class="label" style:background={bgColor}>{label}</div>
Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
size?: number;
99
stiffness?: number;
1010
class?: string;
11+
strokeWidth?: number;
1112
colors?: {
1213
arc?: string;
1314
bg?: string;
@@ -17,12 +18,17 @@
1718
1819
let {
1920
style,
21+
strokeWidth,
2022
class: className,
2123
label = '',
2224
unit = '',
2325
size = 80,
2426
onChange,
2527
value = $bindable(),
28+
step,
29+
acceleration,
30+
maxSpeed,
31+
initialDelay,
2632
defaultValue,
2733
param,
2834
stiffness = 0.5,
@@ -89,23 +95,34 @@
8995
</script>
9096

9197
<KnobBase
98+
{acceleration}
9299
{colors}
93100
{decimalDigits}
94101
{defaultValue}
95102
{disabled}
96103
{draggable}
97104
{label}
105+
{maxSpeed}
98106
{onChange}
99107
{param}
108+
{rotationDegrees}
100109
{snapThreshold}
101110
{snapValues}
111+
{step}
102112
{style}
103113
{unit}
114+
{initialDelay}
104115
bind:value
105-
{rotationDegrees}
106116
>
107-
{#snippet ui({ normalizedValue, handleTouchStart, handleMouseDown, handleDblClick })}
117+
{#snippet ui({
118+
normalizedValue,
119+
handleTouchStart,
120+
handleMouseDown,
121+
handleDblClick,
122+
handleKeyDown
123+
})}
108124
<svg
125+
style="--stroke-width: {strokeWidth ?? lineWidth}px"
109126
class={className}
110127
role="slider"
111128
tabindex="0"
@@ -115,29 +132,32 @@
115132
viewBox="0 0 {size} {size}"
116133
stroke-linecap="round"
117134
stroke-linejoin="round"
118-
stroke-width={lineWidth}
119135
onmousedown={handleMouseDown}
120136
ontouchstart={handleTouchStart}
121137
ondblclick={handleDblClick}
138+
onkeydown={handleKeyDown}
122139
>
123140
<circle cx={center} cy={center} r={circleRadius} fill={bgColor}></circle>
124141
{#if snapValues.length > 0 || param.type === 'enum-param'}
125142
<!-- Snap markers -->
126-
<path d={drawSnapMarkers()} stroke={bgColor} />
143+
<path d={drawSnapMarkers()} stroke={bgColor} stroke-width={strokeWidth ?? lineWidth} />
127144
{/if}
128145
<!-- Arcs -->
129146
<path
147+
class="line"
130148
d={describeArc(center, center, arcRadius, $rotationDegrees, 135)}
131-
fill="none"
132149
stroke={bgColor}
150+
fill="none"
133151
/>
134152
<path
153+
class="line"
135154
d={describeArc(center, center, arcRadius, -135, $rotationDegrees)}
136-
fill="none"
137155
stroke={arcColor2}
156+
fill="none"
138157
/>
139158
<!-- Knob indicator -->
140159
<line
160+
class="line"
141161
x1={center}
142162
y1={center * 0.7}
143163
x2={center}
@@ -148,3 +168,28 @@
148168
</svg>
149169
{/snippet}
150170
</KnobBase>
171+
172+
<style>
173+
svg {
174+
outline: none;
175+
}
176+
177+
.line {
178+
transition: stroke-width 200ms;
179+
stroke-width: var(--stroke-width);
180+
}
181+
182+
.focus {
183+
display: none;
184+
}
185+
186+
svg:active .focus,
187+
svg:focus .focus {
188+
display: block;
189+
}
190+
191+
svg:active .line,
192+
svg:focus .line {
193+
stroke-width: calc(var(--stroke-width) * 1.8);
194+
}
195+
</style>

0 commit comments

Comments
 (0)