{label}
@@ -78,6 +82,7 @@ export function KnobBase({
valueRawDisplayFn={valueRawDisplayFn}
dragSensitivity={dragSensitivity}
axis={axis}
+ disabled={disabled}
mapTo01={mapTo01}
mapFrom01={mapFrom01}
onValueRawChange={setValueRaw}
diff --git a/apps/docs/src/components/knobs/KnobBaseThumb.tsx b/apps/docs/src/components/knobs/KnobBaseThumb.tsx
index 9203265..749ed92 100644
--- a/apps/docs/src/components/knobs/KnobBaseThumb.tsx
+++ b/apps/docs/src/components/knobs/KnobBaseThumb.tsx
@@ -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;
};
@@ -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',
)}
>
diff --git a/apps/docs/src/components/knobs/KnobDisabled.tsx b/apps/docs/src/components/knobs/KnobDisabled.tsx
new file mode 100644
index 0000000..d6ae298
--- /dev/null
+++ b/apps/docs/src/components/knobs/KnobDisabled.tsx
@@ -0,0 +1,63 @@
+'use client';
+import {NormalisableRange} from '@/utils/math/NormalisableRange';
+import {KnobBase} from './KnobBase';
+
+type KnobBaseProps = React.ComponentProps
;
+type KnobDisabledProps = Pick;
+
+export function KnobDisabled(props: KnobDisabledProps) {
+ return (
+
+ );
+}
+
+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);
diff --git a/packages/react-knob-headless/src/KnobHeadless.test.tsx b/packages/react-knob-headless/src/KnobHeadless.test.tsx
index a7aff37..9391f1c 100644
--- a/packages/react-knob-headless/src/KnobHeadless.test.tsx
+++ b/packages/react-knob-headless/src/KnobHeadless.test.tsx
@@ -201,6 +201,29 @@ describe('KnobHeadless', () => {
`);
});
+ it('sets "aria-disabled" and "data-disabled" to true, when "disabled" option is true', () => {
+ render(
+ ,
+ );
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
it('sets tabIndex to 0, when "includeIntoTabOrder" is true', () => {
render(
,
@@ -222,6 +245,55 @@ describe('KnobHeadless', () => {
`);
});
+ it('sets tabIndex to -1, when "includeIntoTabOrder" is true and "disabled" is true', () => {
+ render(
+ ,
+ );
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('sets tabIndex to -1, when "disabled" is true', () => {
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
it('can render children', () => {
render(
diff --git a/packages/react-knob-headless/src/KnobHeadless.tsx b/packages/react-knob-headless/src/KnobHeadless.tsx
index 412feae..0a351b5 100644
--- a/packages/react-knob-headless/src/KnobHeadless.tsx
+++ b/packages/react-knob-headless/src/KnobHeadless.tsx
@@ -15,11 +15,14 @@ type NativeDivPropsToExtend = Omit<
| 'aria-orientation' // Handled by "axis" and "orientation"
| 'aria-label' // Handled by "KnobHeadlessLabelProps"
| 'aria-labelledby' // Handled by "KnobHeadlessLabelProps"
+ | 'aria-disabled' // Handled by "disabled"
+ | 'data-disabled' // Handled by "disabled"
| 'tabIndex' // Handled by "includeIntoTabOrder"
>;
const axisDefault = 'y';
const includeIntoTabOrderDefault = false;
+const disabledDefault = false;
const mapTo01Default = mapTo01Linear;
const mapFrom01Default = mapFrom01Linear;
@@ -82,6 +85,10 @@ type KnobHeadlessProps = NativeDivPropsToExtend &
* In most audio applications, the knob is usually controlled by the mouse / touch, so it's not needed.
*/
readonly includeIntoTabOrder?: boolean;
+ /**
+ * Disabled state, used to prevent component from being manipulated.
+ */
+ readonly disabled?: boolean;
/**
* Used for mapping the value to the normalized knob position (number from 0 to 1).
* This is the place for making the interpolation, if non-linear one is required.
@@ -94,6 +101,9 @@ type KnobHeadlessProps = NativeDivPropsToExtend &
readonly mapFrom01?: (x: number, min: number, max: number) => number;
};
+const getTabIndex = (includeIntoTabOrder: boolean, disabled: boolean) =>
+ !disabled && includeIntoTabOrder ? 0 : -1;
+
export const KnobHeadless = forwardRef(
(
{
@@ -107,6 +117,7 @@ export const KnobHeadless = forwardRef(
orientation,
axis = axisDefault,
includeIntoTabOrder = includeIntoTabOrderDefault,
+ disabled = disabledDefault,
mapTo01 = mapTo01Default,
mapFrom01 = mapFrom01Default,
...rest
@@ -142,6 +153,7 @@ export const KnobHeadless = forwardRef(
keys: false,
},
axis: getDragAxis(orientation, axis),
+ enabled: !disabled,
},
);
/* v8 ignore stop */ // eslint-disable-line capitalized-comments
@@ -155,7 +167,7 @@ export const KnobHeadless = forwardRef(
aria-valuemax={valueMax}
aria-orientation={getAriaOrientation(orientation, axis)}
aria-valuetext={valueRawDisplayFn(valueRaw)}
- tabIndex={includeIntoTabOrder ? 0 : -1}
+ tabIndex={getTabIndex(includeIntoTabOrder, disabled)}
{...mergeProps(
bindDrag(),
{
@@ -163,13 +175,19 @@ export const KnobHeadless = forwardRef(
touchAction: 'none', // It's recommended to disable "touch-action" for use-gesture: https://use-gesture.netlify.app/docs/extras/#touch-action
},
onPointerDown(event: React.PointerEvent) {
- /* v8 ignore start */ // eslint-disable-line capitalized-comments
- // Touch devices have a delay before focusing so it won't focus if touch immediately moves away from target (sliding). We want thumb to focus regardless.
- // See, for reference, Radix UI Slider does the same: https://github.com/radix-ui/primitives/blob/eca6babd188df465f64f23f3584738b85dba610e/packages/react/slider/src/Slider.tsx#L442-L445
- event.currentTarget.focus();
- /* v8 ignore stop */ // eslint-disable-line capitalized-comments
+ if (!disabled) {
+ /* v8 ignore start */ // eslint-disable-line capitalized-comments
+ // Touch devices have a delay before focusing so it won't focus if touch immediately moves away from target (sliding). We want thumb to focus regardless.
+ // See, for reference, Radix UI Slider does the same: https://github.com/radix-ui/primitives/blob/eca6babd188df465f64f23f3584738b85dba610e/packages/react/slider/src/Slider.tsx#L442-L445
+ event.currentTarget.focus();
+ /* v8 ignore stop */ // eslint-disable-line capitalized-comments
+ }
},
},
+ disabled && {
+ 'aria-disabled': true,
+ 'data-disabled': true,
+ },
rest,
)}
/>
@@ -182,6 +200,7 @@ KnobHeadless.displayName = 'KnobHeadless';
KnobHeadless.defaultProps = {
axis: axisDefault,
includeIntoTabOrder: includeIntoTabOrderDefault,
+ disabled: disabledDefault,
mapTo01: mapTo01Default,
mapFrom01: mapFrom01Default,
};
diff --git a/packages/react-knob-headless/src/useKnobKeyboardControls.test.tsx b/packages/react-knob-headless/src/useKnobKeyboardControls.test.tsx
index 051d409..f77f25a 100644
--- a/packages/react-knob-headless/src/useKnobKeyboardControls.test.tsx
+++ b/packages/react-knob-headless/src/useKnobKeyboardControls.test.tsx
@@ -363,9 +363,235 @@ describe('useKnobKeyboardControls', () => {
expect(preventDefaultFn).toHaveBeenCalledTimes(0);
});
+
+ describe('disabled state', () => {
+ it('does not change value on ArrowUp', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{ArrowUp}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on ArrowRight', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{ArrowRight}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on ArrowDown', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{ArrowDown}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on ArrowLeft', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{ArrowLeft}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on PageUp', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{PageUp}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on PageDown', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{PageDown}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on Home', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{Home}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('does not change value on End', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const knob = screen.getByRole('slider', {name: 'Test Knob'});
+
+ await act(async () => {
+ await user.click(knob);
+ await user.keyboard('{End}');
+ });
+
+ expect(knob).toMatchInlineSnapshot(`
+
+ `);
+ });
+ });
});
-function TestComponent() {
+function TestComponent({disabled = false}: {readonly disabled?: boolean}) {
const [valueRaw, setValueRaw] = useState(valueRawDefault);
const keyboardControlHandlers = useKnobKeyboardControls({
valueRaw,
@@ -373,6 +599,7 @@ function TestComponent() {
valueMax,
step,
stepLarger,
+ disabled,
onValueRawChange: setValueRaw,
});
@@ -385,6 +612,7 @@ function TestComponent() {
valueMax={valueMax}
valueRawRoundFn={valueRawRoundFn}
valueRawDisplayFn={valueRawDisplayFn}
+ disabled={disabled}
onValueRawChange={setValueRaw}
{...keyboardControlHandlers}
/>
diff --git a/packages/react-knob-headless/src/useKnobKeyboardControls.ts b/packages/react-knob-headless/src/useKnobKeyboardControls.ts
index faa0037..38feb16 100644
--- a/packages/react-knob-headless/src/useKnobKeyboardControls.ts
+++ b/packages/react-knob-headless/src/useKnobKeyboardControls.ts
@@ -34,6 +34,11 @@ type UseKnobKeyboardControlsProps = {
* However, if your application needs some more customized one, you can set this prop to true and handle scroll prevention on your own.
*/
readonly noDefaultPrevention?: boolean;
+ /**
+ * Disabled state, used to prevent component from being manipulated.
+ * If true, the knob will not respond to keyboard events.
+ */
+ readonly disabled?: boolean;
};
export const useKnobKeyboardControls = ({
@@ -44,8 +49,13 @@ export const useKnobKeyboardControls = ({
stepLarger,
onValueRawChange,
noDefaultPrevention = false,
+ disabled = false,
}: UseKnobKeyboardControlsProps): {onKeyDown: React.KeyboardEventHandler} => {
const onKeyDown: React.KeyboardEventHandler = (event) => {
+ if (disabled) {
+ return;
+ }
+
const {code} = event;
switch (code) {
case 'ArrowUp':