Skip to content

Commit

Permalink
useKnobKeyboardControls (v0.3.0) (#52)
Browse files Browse the repository at this point in the history
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
satelllte and dependabot[bot] committed Jan 14, 2024
1 parent 65b9c80 commit 26c10b2
Show file tree
Hide file tree
Showing 17 changed files with 2,100 additions and 517 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: mcr.microsoft.com/playwright:v1.39.0-jammy
image: mcr.microsoft.com/playwright:v1.40.1-jammy
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/e2e/expects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ const _knobDragsCorrectly =
direction === 'right'
? x + dragAmplitude
: direction === 'left'
? x - dragAmplitude
: x;
? x - dragAmplitude
: x;
y =
direction === 'down'
? y + dragAmplitude
: direction === 'up'
? y - dragAmplitude
: y;
? y - dragAmplitude
: y;

await page.mouse.down();
await page.mouse.move(x, y, {steps: dragSteps});
Expand Down
75 changes: 72 additions & 3 deletions apps/docs/e2e/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,14 @@ test.describe('"Simple linear knob" example', () => {

test.describe('"Dry/Wet" knob', () => {
let knob: Locator;
let knobOutput: Locator;

test.beforeEach(() => {
knob = locators.exampleKnob({container, name: 'Dry/Wet'});
knobOutput = locators.exampleKnobOutput({container});
});

test('has correct default value', async () => {
const knobOutput = locators.exampleKnobOutput({container});
await expects.knobValueIsEqualTo({knob, valueNow: 50});
await expects.knobValueTextIs({knob, knobOutput, valueText: '50%'});
});
Expand All @@ -96,6 +97,42 @@ test.describe('"Simple linear knob" example', () => {
test('has correct drag up behaviour', async ({page}) => {
await expects.knobDragsUpCorrectly({knob, valueNow: 50, page});
});

test('has correct keyboard controls behaviour', async ({page}) => {
await knob.click();

await page.keyboard.press('ArrowDown');
await expects.knobValueIsEqualTo({knob, valueNow: 49});
await expects.knobValueTextIs({knob, knobOutput, valueText: '49%'});

await page.keyboard.press('ArrowLeft');
await expects.knobValueIsEqualTo({knob, valueNow: 48});
await expects.knobValueTextIs({knob, knobOutput, valueText: '48%'});

await page.keyboard.press('ArrowUp');
await expects.knobValueIsEqualTo({knob, valueNow: 49});
await expects.knobValueTextIs({knob, knobOutput, valueText: '49%'});

await page.keyboard.press('ArrowRight');
await expects.knobValueIsEqualTo({knob, valueNow: 50});
await expects.knobValueTextIs({knob, knobOutput, valueText: '50%'});

await page.keyboard.press('PageUp');
await expects.knobValueIsEqualTo({knob, valueNow: 60});
await expects.knobValueTextIs({knob, knobOutput, valueText: '60%'});

await page.keyboard.press('PageDown');
await expects.knobValueIsEqualTo({knob, valueNow: 50});
await expects.knobValueTextIs({knob, knobOutput, valueText: '50%'});

await page.keyboard.press('Home');
await expects.knobValueIsEqualTo({knob, valueNow: 0});
await expects.knobValueTextIs({knob, knobOutput, valueText: '0%'});

await page.keyboard.press('End');
await expects.knobValueIsEqualTo({knob, valueNow: 100});
await expects.knobValueTextIs({knob, knobOutput, valueText: '100%'});
});
});
});

Expand All @@ -119,13 +156,14 @@ test.describe('"Interpolated knob" example', () => {

test.describe('"Frequency" knob', () => {
let knob: Locator;
let knobOutput: Locator;

test.beforeEach(() => {
knob = locators.exampleKnob({container, name: 'Frequency'});
knobOutput = locators.exampleKnobOutput({container});
});

test('has correct default value', async () => {
const knobOutput = locators.exampleKnobOutput({container});
await expects.knobValueIsEqualTo({knob, valueNow: 440});
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 Hz'});
});
Expand All @@ -137,6 +175,36 @@ test.describe('"Interpolated knob" example', () => {
test('has correct drag up behaviour', async ({page}) => {
await expects.knobDragsUpCorrectly({knob, valueNow: 440, page});
});

test('has correct keyboard controls behaviour', async ({page}) => {
await knob.click();

// NOTE: we don't check `knobValueIsEqualTo` because there's no rounding in the frequency knob

await page.keyboard.press('ArrowDown');
await expects.knobValueTextIs({knob, knobOutput, valueText: '430 Hz'});

await page.keyboard.press('ArrowLeft');
await expects.knobValueTextIs({knob, knobOutput, valueText: '420 Hz'});

await page.keyboard.press('ArrowUp');
await expects.knobValueTextIs({knob, knobOutput, valueText: '430 Hz'});

await page.keyboard.press('ArrowRight');
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 Hz'});

await page.keyboard.press('PageUp');
await expects.knobValueTextIs({knob, knobOutput, valueText: '540 Hz'});

await page.keyboard.press('PageDown');
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 Hz'});

await page.keyboard.press('Home');
await expects.knobValueTextIs({knob, knobOutput, valueText: '20.0 Hz'});

await page.keyboard.press('End');
await expects.knobValueTextIs({knob, knobOutput, valueText: '20.0 kHz'});
});
});
});

Expand All @@ -163,13 +231,14 @@ test.describe('"Horizontal orientation" example', () => {

test.describe('"X" knob', () => {
let knob: Locator;
let knobOutput: Locator;

test.beforeEach(() => {
knob = locators.exampleKnob({container, name: 'X'});
knobOutput = locators.exampleKnobOutput({container});
});

test('has correct default value', async () => {
const knobOutput = locators.exampleKnobOutput({container});
await expects.knobValueIsEqualTo({knob, valueNow: 50});
await expects.knobValueTextIs({knob, knobOutput, valueText: '50%'});
});
Expand Down
17 changes: 8 additions & 9 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,19 @@
},
"dependencies": {
"@radix-ui/react-icons": "1.3.0",
"clsx": "2.0.0",
"next": "13.5.4",
"clsx": "2.1.0",
"next": "14.0.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-knob-headless": "*"
},
"devDependencies": {
"@playwright/test": "1.39.0",
"@types/node": "20.8.6",
"@types/react": "18.2.28",
"@types/react-dom": "18.2.13",
"@playwright/test": "1.40.1",
"@types/node": "20.10.6",
"@types/react": "18.2.46",
"@types/react-dom": "18.2.18",
"autoprefixer": "10.4.16",
"postcss": "8.4.31",
"tailwindcss": "3.3.3",
"vitest": "0.34.6"
"postcss": "8.4.32",
"tailwindcss": "3.4.0"
}
}
51 changes: 45 additions & 6 deletions apps/docs/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,6 @@ function IndexPage() {
&quot;package.json&quot; file by installing it via
&quot;--save-exact&quot; flag.
</Li>
<Li>
There is no keyboard interaction provided by default. This is
intentional, as knob behaviours might differ significantly between
use cases. To achieve keyboard interaction, you can just provide
your own &quot;onKeyDown&quot; listener.
</Li>
</Ul>
</Section>
<Section title='API'>
Expand Down Expand Up @@ -230,6 +224,51 @@ function IndexPage() {
},
]}
/>
<ComponentDocumentation
name='useKnobKeyboardControls'
about='A primitive for enabling keyboard controls.'
properties={[
{
name: 'valueRaw',
type: 'number',
description: 'Same as "valueRaw" prop of "KnobHeadless".',
},
{
name: 'valueMin',
type: 'number',
description: 'Same as "valueMin" prop of "KnobHeadless".',
},
{
name: 'valueMax',
type: 'number',
description: 'Same as "valueMax" prop of "KnobHeadless".',
},
{
name: 'step',
type: 'number',
description: "Step value. Typically it's 1% of the range.",
},
{
name: 'stepLarger',
type: 'number',
description:
"Larger step value. Typically it's 10% of the range.",
},
{
name: 'onValueRawChange',
type: 'function',
description:
'Same callback as "KnobHeadless" has, with "event" in 2nd argument.',
},
{
name: 'noDefaultPrevention',
type: 'boolean',
defaultValue: 'false',
description:
'To prevent scrolling, "event.preventDefault()" is called when the value changes, but for most cases you don\'t need to change this behaviour. However, if your application needs some more customized one, you can set this prop to true and handle scroll prevention on your own.',
},
]}
/>
</div>
</Section>
</div>
Expand Down
19 changes: 18 additions & 1 deletion apps/docs/src/components/knobs/KnobBase.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';
import clsx from 'clsx';
import {useId, useState} from 'react';
import {
KnobHeadless,
KnobHeadlessLabel,
KnobHeadlessOutput,
useKnobKeyboardControls,
} from 'react-knob-headless';
import {mapFrom01Linear, mapTo01Linear} from '@dsp-ts/math';
import {KnobBaseThumb} from './KnobBaseThumb';
Expand All @@ -24,6 +24,8 @@ type KnobBaseProps = Pick<
Pick<KnobBaseThumbProps, 'theme'> & {
readonly label: string;
readonly valueDefault: number;
readonly stepFn: (valueRaw: number) => number;
readonly stepLargerFn: (valueRaw: number) => number;
};

export function KnobBase({
Expand All @@ -35,14 +37,28 @@ export function KnobBase({
valueRawRoundFn,
valueRawDisplayFn,
orientation,
stepFn,
stepLargerFn,
mapTo01 = mapTo01Linear,
mapFrom01 = mapFrom01Linear,
}: KnobBaseProps) {
const knobId = useId();
const labelId = useId();
const [valueRaw, setValueRaw] = useState<number>(valueDefault);
const value01 = mapTo01(valueRaw, valueMin, valueMax);
const step = stepFn(valueRaw);
const stepLarger = stepLargerFn(valueRaw);
const dragSensitivity = 0.006;

const keyboardControlHandlers = useKnobKeyboardControls({
valueRaw,
valueMin,
valueMax,
step,
stepLarger,
onValueRawChange: setValueRaw,
});

return (
<div
className={clsx(
Expand All @@ -65,6 +81,7 @@ export function KnobBase({
mapTo01={mapTo01}
mapFrom01={mapFrom01}
onValueRawChange={setValueRaw}
{...keyboardControlHandlers}
>
<KnobBaseThumb theme={theme} value01={value01} />
</KnobHeadless>
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/src/components/knobs/KnobFrequency.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function KnobFrequency(props: KnobFrequencyProps) {
valueDefault={valueDefault}
valueMin={valueMin}
valueMax={valueMax}
stepFn={stepFn}
stepLargerFn={stepLargerFn}
valueRawRoundFn={valueRawRoundFn}
valueRawDisplayFn={valueRawDisplayFn}
mapTo01={mapTo01}
Expand All @@ -26,6 +28,19 @@ export function KnobFrequency(props: KnobFrequencyProps) {
const valueMin = 20;
const valueMax = 20000;
const valueDefault = 440;
const stepFn = (valueRaw: number): number => {
if (valueRaw < 100) {
return 1;
}

if (valueRaw < 1000) {
return 10;
}

return 100;
};

const stepLargerFn = (valueRaw: number): number => stepFn(valueRaw) * 10;
const valueRawRoundFn = (x: number): number => x;
const valueRawDisplayFn = (hz: number): string => {
if (hz < 100) {
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/src/components/knobs/KnobPercentage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export function KnobPercentage(props: KnobPercentageProps) {
valueDefault={valueDefault}
valueMin={valueMin}
valueMax={valueMax}
stepFn={stepFn}
stepLargerFn={stepLargerFn}
valueRawRoundFn={valueRawRoundFn}
valueRawDisplayFn={valueRawDisplayFn}
{...props}
Expand All @@ -23,6 +25,8 @@ export function KnobPercentage(props: KnobPercentageProps) {
const valueMin = 0;
const valueMax = 100;
const valueDefault = 50;
const stepFn = (valueRaw: number): number => 1;
const stepLargerFn = (valueRaw: number): number => 10;
const valueRawRoundFn = Math.round;
const valueRawDisplayFn = (valueRaw: number): string =>
`${valueRawRoundFn(valueRaw)}%`;
Loading

0 comments on commit 26c10b2

Please sign in to comment.