Skip to content
8 changes: 8 additions & 0 deletions apps/docs/e2e/expects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const _knobDragsCorrectly =
async ({knob, valueNow, page}: KnobDragAssertionProps) => {
const dragSteps = 10;
const dragAmplitude = 40.0;
const isDisabled =
(await knob.getAttribute('aria-disabled')) === 'true' &&
(await knob.getAttribute('data-disabled')) === 'true';

// It's necessary to hover over the knob and scroll it into view,
// so then we can calculate its bounds in the viewport properly
Expand All @@ -37,6 +40,11 @@ const _knobDragsCorrectly =
await page.mouse.move(x, y, {steps: dragSteps});
await page.mouse.up();

if (isDisabled) {
await knobValueIsEqualTo({knob, valueNow});
return;
}

if (direction === 'up' || direction === 'right') {
await knobValueIsMoreThan({knob, value: valueNow});
} else {
Expand Down
70 changes: 70 additions & 0 deletions apps/docs/e2e/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,73 @@ test.describe('"Vertical and horizontal orientation" example', () => {
});
});
});

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

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

test('has "View source" link leading to "KnobDisabled.tsx" source code file', async ({
page,
}) => {
const viewSourceLink = locators.exampleViewSourceLink({container});
await expects.sourceCodeLinkIsValid({
link: viewSourceLink,
page,
filePath: 'apps/docs/src/components/knobs/KnobDisabled.tsx',
});
});

test('has correct default value', async () => {
await expects.knobValueIsEqualTo({knob, valueNow: 440});
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 Hz'});
});

test('has correct drag down behaviour', async ({page}) => {
await expects.knobDragsDownCorrectly({knob, valueNow: 440, page});
});

test('has correct drag up behaviour', async ({page}) => {
await expects.knobDragsUpCorrectly({knob, valueNow: 440, page});
});

test('has correct keyboard controls behaviour', async ({page}) => {
// NOTE: we want to click it although it's not interactive to check if keyboard controls are ignored
await knob.click({force: true});

// 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: '440 Hz'});

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

await page.keyboard.press('ArrowUp');
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 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: '440 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: '440 Hz'});

await page.keyboard.press('End');
await expects.knobValueTextIs({knob, knobOutput, valueText: '440 Hz'});
});
});
22 changes: 22 additions & 0 deletions apps/docs/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {KnobPercentageX} from '@/components/knobs/KnobPercentageX';
import {KnobPercentageXY} from '@/components/knobs/KnobPercentageXY';
import {ExternalLinkUnstyled} from '@/components/ui/ExternalLinkUnstyled';
import {TableApi} from '@/components/ui/TableApi';
import {KnobDisabled} from '@/components/knobs/KnobDisabled';

function IndexPage() {
return (
Expand Down Expand Up @@ -103,6 +104,13 @@ function IndexPage() {
>
<KnobPercentageXY label='XY' theme='pink' />
</Example>
<Example
title='Disabled knob'
description='Knob can be disabled to prevent manipulation'
source='https://github.com/satelllte/react-knob-headless/blob/main/apps/docs/src/components/knobs/KnobDisabled.tsx'
>
<KnobDisabled label='Disabled' theme='gray' />
</Example>
</div>
</Section>
<Section title='Gotchas'>
Expand Down Expand Up @@ -199,6 +207,13 @@ function IndexPage() {
type: 'string',
description: 'Labelling for accesibility purposes.',
},
{
name: 'disabled',
type: 'boolean',
defaultValue: 'false',
description:
'Disabled state, used to prevent component from being manipulated. If true, the knob will not respond to mouse / touch / keyboard events.',
},
{
name: '...rest',
type: '...',
Expand Down Expand Up @@ -283,6 +298,13 @@ function IndexPage() {
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.',
},
{
name: 'disabled',
type: 'boolean',
defaultValue: 'false',
description:
'Disabled state, used to prevent component from being manipulated. If true, the knob will not respond to keyboard events.',
},
]}
/>
</div>
Expand Down
7 changes: 6 additions & 1 deletion apps/docs/src/components/knobs/KnobBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type KnobBaseProps = Pick<
| 'axis'
| 'mapTo01'
| 'mapFrom01'
| 'disabled'
> &
Pick<KnobBaseThumbProps, 'theme'> & {
readonly label: string;
Expand All @@ -37,6 +38,7 @@ export function KnobBase({
valueRawRoundFn,
valueRawDisplayFn,
axis,
disabled,
stepFn,
stepLargerFn,
mapTo01 = mapTo01Linear,
Expand All @@ -56,14 +58,16 @@ export function KnobBase({
valueMax,
step,
stepLarger,
disabled,
onValueRawChange: setValueRaw,
});

return (
<div
className={clsx(
'w-16 flex flex-col gap-0.5 justify-center items-center text-xs select-none',
'outline-none focus-within:outline-1 focus-within:outline-offset-4 focus-within:outline-stone-300',
!disabled &&
'outline-none focus-within:outline-1 focus-within:outline-offset-4 focus-within:outline-stone-300',
)}
>
<KnobHeadlessLabel id={labelId}>{label}</KnobHeadlessLabel>
Expand All @@ -78,6 +82,7 @@ export function KnobBase({
valueRawDisplayFn={valueRawDisplayFn}
dragSensitivity={dragSensitivity}
axis={axis}
disabled={disabled}
mapTo01={mapTo01}
mapFrom01={mapFrom01}
onValueRawChange={setValueRaw}
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/src/components/knobs/KnobBaseThumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import clsx from 'clsx';
import {mapFrom01Linear} from '@dsp-ts/math';

type KnobBaseThumbProps = {
readonly theme: 'stone' | 'pink' | 'green' | 'sky';
readonly theme: 'stone' | 'pink' | 'green' | 'sky' | 'gray';
readonly value01: number;
};

Expand All @@ -18,6 +18,7 @@ export function KnobBaseThumb({theme, value01}: KnobBaseThumbProps) {
theme === 'pink' && 'bg-pink-300',
theme === 'green' && 'bg-green-300',
theme === 'sky' && 'bg-sky-300',
theme === 'gray' && 'bg-neutral-700',
)}
>
<div className='absolute h-full w-full' style={{rotate: `${angle}deg`}}>
Expand Down
63 changes: 63 additions & 0 deletions apps/docs/src/components/knobs/KnobDisabled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';
import {NormalisableRange} from '@/utils/math/NormalisableRange';
import {KnobBase} from './KnobBase';

type KnobBaseProps = React.ComponentProps<typeof KnobBase>;
type KnobDisabledProps = Pick<KnobBaseProps, 'theme' | 'label' | 'axis'>;

export function KnobDisabled(props: KnobDisabledProps) {
return (
<KnobBase
disabled
valueDefault={valueDefault}
valueMin={valueMin}
valueMax={valueMax}
stepFn={stepFn}
stepLargerFn={stepLargerFn}
valueRawRoundFn={valueRawRoundFn}
valueRawDisplayFn={valueRawDisplayFn}
mapTo01={mapTo01}
mapFrom01={mapFrom01}
{...props}
/>
);
}

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) {
return `${hz.toFixed(1)} Hz`;
}

if (hz < 1000) {
return `${hz.toFixed(0)} Hz`;
}

const kHz = hz / 1000;

if (hz < 10000) {
return `${kHz.toFixed(2)} kHz`;
}

return `${kHz.toFixed(1)} kHz`;
};

const normalisableRange = new NormalisableRange(valueMin, valueMax, 1000);
const mapTo01 = (x: number) => normalisableRange.mapTo01(x);
const mapFrom01 = (x: number) => normalisableRange.mapFrom01(x);
72 changes: 72 additions & 0 deletions packages/react-knob-headless/src/KnobHeadless.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,29 @@ describe('KnobHeadless', () => {
`);
});

it('sets "aria-disabled" and "data-disabled" to true, when "disabled" option is true', () => {
render(
<KnobHeadless {...props} disabled axis='y' aria-label='Test Knob' />,
);

const knob = screen.getByRole('slider', {name: 'Test Knob'});

expect(knob).toMatchInlineSnapshot(`
<div
aria-disabled="true"
aria-label="Test Knob"
aria-orientation="vertical"
aria-valuemax="5"
aria-valuemin="-5"
aria-valuenow="2"
aria-valuetext="2 units"
data-disabled="true"
role="slider"
tabindex="-1"
/>
`);
});

it('sets tabIndex to 0, when "includeIntoTabOrder" is true', () => {
render(
<KnobHeadless {...props} includeIntoTabOrder aria-label='Test Knob' />,
Expand All @@ -222,6 +245,55 @@ describe('KnobHeadless', () => {
`);
});

it('sets tabIndex to -1, when "includeIntoTabOrder" is true and "disabled" is true', () => {
render(
<KnobHeadless
{...props}
includeIntoTabOrder
disabled
aria-label='Test Knob'
/>,
);

const knob = screen.getByRole('slider', {name: 'Test Knob'});

expect(knob).toMatchInlineSnapshot(`
<div
aria-disabled="true"
aria-label="Test Knob"
aria-orientation="vertical"
aria-valuemax="5"
aria-valuemin="-5"
aria-valuenow="2"
aria-valuetext="2 units"
data-disabled="true"
role="slider"
tabindex="-1"
/>
`);
});

it('sets tabIndex to -1, when "disabled" is true', () => {
render(<KnobHeadless {...props} disabled aria-label='Test Knob' />);

const knob = screen.getByRole('slider', {name: 'Test Knob'});

expect(knob).toMatchInlineSnapshot(`
<div
aria-disabled="true"
aria-label="Test Knob"
aria-orientation="vertical"
aria-valuemax="5"
aria-valuemin="-5"
aria-valuenow="2"
aria-valuetext="2 units"
data-disabled="true"
role="slider"
tabindex="-1"
/>
`);
});

it('can render children', () => {
render(
<KnobHeadless {...props} aria-label='Test Knob'>
Expand Down
Loading