diff --git a/apps/docs/e2e/expects.ts b/apps/docs/e2e/expects.ts index 5827c13..dbef517 100644 --- a/apps/docs/e2e/expects.ts +++ b/apps/docs/e2e/expects.ts @@ -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 @@ -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 { diff --git a/apps/docs/e2e/index.spec.ts b/apps/docs/e2e/index.spec.ts index 2760617..170bb2f 100644 --- a/apps/docs/e2e/index.spec.ts +++ b/apps/docs/e2e/index.spec.ts @@ -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'}); + }); +}); diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx index 4b06110..ef04b3f 100644 --- a/apps/docs/src/app/page.tsx +++ b/apps/docs/src/app/page.tsx @@ -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 ( @@ -103,6 +104,13 @@ function IndexPage() { > + + +
@@ -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: '...', @@ -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.', + }, ]} /> diff --git a/apps/docs/src/components/knobs/KnobBase.tsx b/apps/docs/src/components/knobs/KnobBase.tsx index 8005b52..d924117 100644 --- a/apps/docs/src/components/knobs/KnobBase.tsx +++ b/apps/docs/src/components/knobs/KnobBase.tsx @@ -20,6 +20,7 @@ type KnobBaseProps = Pick< | 'axis' | 'mapTo01' | 'mapFrom01' + | 'disabled' > & Pick & { readonly label: string; @@ -37,6 +38,7 @@ export function KnobBase({ valueRawRoundFn, valueRawDisplayFn, axis, + disabled, stepFn, stepLargerFn, mapTo01 = mapTo01Linear, @@ -56,6 +58,7 @@ export function KnobBase({ valueMax, step, stepLarger, + disabled, onValueRawChange: setValueRaw, }); @@ -63,7 +66,8 @@ export function KnobBase({
{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':