diff --git a/.changeset/slider-number-input-step-modifiers.md b/.changeset/slider-number-input-step-modifiers.md new file mode 100644 index 0000000000..026a2e78f8 --- /dev/null +++ b/.changeset/slider-number-input-step-modifiers.md @@ -0,0 +1,15 @@ +--- +"@zag-js/number-input": minor +"@zag-js/slider": minor +--- + +Fixed issue where `Cmd`/`Ctrl` + arrow keys produced values off the `step` grid (e.g. non-integer values when +`step: 1`). Stepping with modifier keys now stays aligned to `step`. + +Replaced the implicit modifier-based stepping with explicit, configurable props: + +- **Slider**: added `largeStep` (defaults to `10 * step`) used when `Shift` or `PageUp`/`PageDown` is pressed. +- **Number Input**: added `largeStep` (defaults to `10 * step`, on `Shift`) and `smallStep` (defaults to `step / 10`, on + `Alt`). + +The defaults preserve the previous stepping magnitudes, so existing behavior is unchanged unless the new props are set. diff --git a/.nvmrc b/.nvmrc index a45fd52cc5..b832e4001d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +24.16.0 diff --git a/e2e/number-input.e2e.ts b/e2e/number-input.e2e.ts index dfcefb495d..79498dd24f 100644 --- a/e2e/number-input.e2e.ts +++ b/e2e/number-input.e2e.ts @@ -100,18 +100,33 @@ test.describe("number input", () => { await I.seeInputHasValue("0") }) - test("ctrl+arrowup: should change for 0.1 steps", async () => { - await I.controls.num("step", "0.1") + test("alt+arrowup: should change by smallStep", async () => { + await I.type("0") + + await I.pressKey("Alt+ArrowUp") + await I.seeInputHasValue("0.1") - await I.type("0.10", { delay: 20 }) - await I.pressKey("Control+ArrowUp") - await I.seeInputHasValue("0.11") + await I.pressKey("Alt+ArrowUp") + await I.seeInputHasValue("0.2") - await I.pressKey("Control+ArrowDown") + await I.pressKey("Alt+ArrowDown") await I.seeInputHasValue("0.1") + }) - await I.pressKey("ArrowDown") - await I.seeInputHasValue("0") + test("should respect custom smallStep and largeStep", async () => { + await I.controls.num("largeStep", "5") + await I.controls.num("smallStep", "0.25") + + await I.type("0") + + await I.pressKey("Shift+ArrowUp") + await I.seeInputHasValue("5") + + await I.pressKey("Alt+ArrowUp") + await I.seeInputHasValue("5.25") + + await I.pressKey("Alt+ArrowDown") + await I.seeInputHasValue("5") }) test("inc click: should increment value", async () => { diff --git a/e2e/slider.e2e.ts b/e2e/slider.e2e.ts index cf7acdffed..173e077319 100644 --- a/e2e/slider.e2e.ts +++ b/e2e/slider.e2e.ts @@ -54,6 +54,37 @@ test.describe("slider", () => { await I.seeValueText("0") }) + test("[keyboard] should stay on the step grid regardless of modifier keys", async () => { + await I.focusThumb() + + // meta/ctrl/alt + arrow should move by `step`, never a fraction of it + await I.pressKey("Meta+ArrowRight") + await I.seeValueText("1") + + await I.pressKey("Control+ArrowRight") + await I.seeValueText("2") + + await I.pressKey("Alt+ArrowRight") + await I.seeValueText("3") + + await I.pressKey("Alt+ArrowLeft") + await I.seeValueText("2") + }) + + test("[keyboard] should respect a custom largeStep", async () => { + await I.controls.num("largeStep", "5") + await I.focusThumb() + + await I.pressKey("Shift+ArrowRight") + await I.seeValueText("5") + + await I.pressKey("PageUp") + await I.seeValueText("10") + + await I.pressKey("PageDown") + await I.seeValueText("5") + }) + test("[pointer] should set value on click track", async () => { await I.mousedownAt({ x: 0.8 }) await I.seeThumbIsFocused() diff --git a/packages/docs/data/accessibility.json b/packages/docs/data/accessibility.json index f0e9513631..842e7be39e 100644 --- a/packages/docs/data/accessibility.json +++ b/packages/docs/data/accessibility.json @@ -335,19 +335,19 @@ }, { "keys": ["PageUp"], - "description": "Increases the value by a larger step" + "description": "Increases the value by the largeStep amount." }, { "keys": ["PageDown"], - "description": "Decreases the value by a larger step" + "description": "Decreases the value by the largeStep amount." }, { "keys": ["Shift + ArrowUp"], - "description": "Increases the value by a larger step" + "description": "Increases the value by the largeStep amount." }, { "keys": ["Shift + ArrowDown"], - "description": "Decreases the value by a larger step" + "description": "Decreases the value by the largeStep amount." }, { "keys": ["Home"], @@ -710,12 +710,20 @@ "description": "Decrements the value of the number input by a predefined step." }, { - "keys": ["PageUp"], - "description": "Increments the value of the number input by a larger predefined step." + "keys": ["Shift + ArrowUp"], + "description": "Increments the value of the number input by the `largeStep` amount." }, { - "keys": ["PageDown"], - "description": "Decrements the value of the number input by a larger predefined step." + "keys": ["Shift + ArrowDown"], + "description": "Decrements the value of the number input by the `largeStep` amount." + }, + { + "keys": ["Alt + ArrowUp"], + "description": "Increments the value of the number input by the `smallStep` amount." + }, + { + "keys": ["Alt + ArrowDown"], + "description": "Decrements the value of the number input by the `smallStep` amount." }, { "keys": ["Home"], diff --git a/packages/docs/data/api.json b/packages/docs/data/api.json index 293dca73a0..11ce064a6b 100644 --- a/packages/docs/data/api.json +++ b/packages/docs/data/api.json @@ -3768,6 +3768,16 @@ "description": "The amount to increment or decrement the value by", "defaultValue": "1" }, + "largeStep": { + "type": "number | undefined", + "description": "The amount to increment or decrement the value by when the `Shift` key is held.", + "defaultValue": "10 * step" + }, + "smallStep": { + "type": "number | undefined", + "description": "The amount to increment or decrement the value by when the `Alt` key is held.", + "defaultValue": "step / 10" + }, "allowMouseWheel": { "type": "boolean | undefined", "description": "Whether to allow mouse wheel to change the value" @@ -5240,6 +5250,11 @@ "description": "The step value of the slider", "defaultValue": "1" }, + "largeStep": { + "type": "number | undefined", + "description": "The step value of the slider when the `Shift` key is held, or the\n`PageUp`/`PageDown` keys are used.", + "defaultValue": "10 * step" + }, "minStepsBetweenThumbs": { "type": "number | undefined", "description": "The minimum permitted steps between multiple thumbs.\n\n`minStepsBetweenThumbs` * `step` should reflect the gap between the thumbs.\n\n- `step: 1` and `minStepsBetweenThumbs: 10` => gap is `10`\n- `step: 10` and `minStepsBetweenThumbs: 2` => gap is `20`", diff --git a/packages/machines/number-input/src/number-input.connect.ts b/packages/machines/number-input/src/number-input.connect.ts index aeb62e9717..74138a2dcc 100644 --- a/packages/machines/number-input/src/number-input.connect.ts +++ b/packages/machines/number-input/src/number-input.connect.ts @@ -2,7 +2,7 @@ import { ariaAttr, dataAttr, getEventPoint, - getEventStep, + getEventStepValue, getWindow, isComposingEvent, isLeftClick, @@ -177,7 +177,11 @@ export function connect( if (readOnly) return if (isComposingEvent(event)) return - const step = getEventStep(event) * prop("step") + const step = getEventStepValue(event, { + step: prop("step"), + largeStep: prop("largeStep"), + smallStep: prop("smallStep"), + }) const keyMap: EventKeyMap = { ArrowUp() { diff --git a/packages/machines/number-input/src/number-input.machine.ts b/packages/machines/number-input/src/number-input.machine.ts index 662c5ca459..8ce0dd5d11 100644 --- a/packages/machines/number-input/src/number-input.machine.ts +++ b/packages/machines/number-input/src/number-input.machine.ts @@ -44,6 +44,8 @@ export const machine = createMachine({ max: Number.MAX_SAFE_INTEGER, spinOnPress: true, ...props, + largeStep: props.largeStep ?? 10 * step, + smallStep: props.smallStep ?? step / 10, translations: { incrementLabel: "increment value", decrementLabel: "decrease value", diff --git a/packages/machines/number-input/src/number-input.props.ts b/packages/machines/number-input/src/number-input.props.ts index 0147fe2f58..1248e695ec 100644 --- a/packages/machines/number-input/src/number-input.props.ts +++ b/packages/machines/number-input/src/number-input.props.ts @@ -16,6 +16,7 @@ export const props = createProps()([ "ids", "inputMode", "invalid", + "largeStep", "locale", "max", "min", @@ -27,6 +28,7 @@ export const props = createProps()([ "pattern", "required", "readOnly", + "smallStep", "spinOnPress", "step", "translations", diff --git a/packages/machines/number-input/src/number-input.types.ts b/packages/machines/number-input/src/number-input.types.ts index ea1d399a3b..7bea1d7272 100644 --- a/packages/machines/number-input/src/number-input.types.ts +++ b/packages/machines/number-input/src/number-input.types.ts @@ -111,6 +111,16 @@ export interface NumberInputProps extends LocaleProperties, CommonProperties { * @default 1 */ step?: number | undefined + /** + * The amount to increment or decrement the value by when the `Shift` key is held. + * @default 10 * step + */ + largeStep?: number | undefined + /** + * The amount to increment or decrement the value by when the `Alt` key is held. + * @default step / 10 + */ + smallStep?: number | undefined /** * Whether to allow mouse wheel to change the value */ @@ -177,11 +187,11 @@ type PropsWithDefault = | "pattern" | "translations" | "step" + | "largeStep" + | "smallStep" | "spinOnPress" | "min" | "max" - | "step" - | "translations" type ComputedContext = Readonly<{ /** diff --git a/packages/machines/slider/src/slider.connect.ts b/packages/machines/slider/src/slider.connect.ts index bf30de61ee..016982d558 100644 --- a/packages/machines/slider/src/slider.connect.ts +++ b/packages/machines/slider/src/slider.connect.ts @@ -3,7 +3,7 @@ import { dataAttr, getEventKey, getEventPoint, - getEventStep, + getEventStepValue, isLeftClick, isModifierKey, } from "@zag-js/dom-query" @@ -213,7 +213,7 @@ export function connect(service: SliderService, normalize: if (event.defaultPrevented) return if (!interactive) return - const step = getEventStep(event) * prop("step") + const step = getEventStepValue(event, { step: prop("step"), largeStep: prop("largeStep") }) const keyMap: EventKeyMap = { ArrowUp() { diff --git a/packages/machines/slider/src/slider.machine.ts b/packages/machines/slider/src/slider.machine.ts index 9cabca324f..c6ba28f098 100644 --- a/packages/machines/slider/src/slider.machine.ts +++ b/packages/machines/slider/src/slider.machine.ts @@ -58,6 +58,7 @@ export const machine = createMachine({ thumbCollisionBehavior: "none", minStepsBetweenThumbs, ...props, + largeStep: props.largeStep ?? 10 * step, defaultValue: normalize(defaultValue, min, max, step, minStepsBetweenThumbs), value: props.value ? normalize(props.value, min, max, step, minStepsBetweenThumbs) : undefined, max, diff --git a/packages/machines/slider/src/slider.props.ts b/packages/machines/slider/src/slider.props.ts index 0303047591..73979eba2a 100644 --- a/packages/machines/slider/src/slider.props.ts +++ b/packages/machines/slider/src/slider.props.ts @@ -13,6 +13,7 @@ export const props = createProps()([ "id", "ids", "invalid", + "largeStep", "max", "min", "minStepsBetweenThumbs", diff --git a/packages/machines/slider/src/slider.types.ts b/packages/machines/slider/src/slider.types.ts index 154d4bd98a..3d91ff8581 100644 --- a/packages/machines/slider/src/slider.types.ts +++ b/packages/machines/slider/src/slider.types.ts @@ -112,6 +112,12 @@ export interface SliderProps extends DirectionProperty, CommonProperties { * @default 1 */ step?: number | undefined + /** + * The step value of the slider when the `Shift` key is held, or the + * `PageUp`/`PageDown` keys are used. + * @default 10 * step + */ + largeStep?: number | undefined /** * The minimum permitted steps between multiple thumbs. * @@ -166,6 +172,7 @@ type PropsWithDefault = | "min" | "max" | "step" + | "largeStep" | "orientation" | "defaultValue" | "origin" diff --git a/packages/utilities/dom-query/src/event.ts b/packages/utilities/dom-query/src/event.ts index ea7eef26e6..bff8e0af1b 100644 --- a/packages/utilities/dom-query/src/event.ts +++ b/packages/utilities/dom-query/src/event.ts @@ -120,14 +120,39 @@ export function getNativeEvent(event: E): NativeEvent { const pageKeys = new Set(["PageUp", "PageDown"]) const arrowKeys = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]) -export function getEventStep(event: Pick) { - if (event.ctrlKey || event.metaKey) { - return 0.1 - } else { - const isPageKey = pageKeys.has(event.key) - const isSkipKey = isPageKey || (event.shiftKey && arrowKeys.has(event.key)) - return isSkipKey ? 10 : 1 +export function getEventStep(event: Pick) { + const isPageKey = pageKeys.has(event.key) + const isSkipKey = isPageKey || (event.shiftKey && arrowKeys.has(event.key)) + return isSkipKey ? 10 : 1 +} + +interface EventStepOptions { + /** + * The default step amount (no modifier key pressed) + */ + step: number + /** + * The step amount when `Shift` or `PageUp`/`PageDown` is used + */ + largeStep: number + /** + * The step amount when `Alt` is used. When omitted, the `Alt` key + * has no effect and the default `step` is used. + */ + smallStep?: number | undefined +} + +export function getEventStepValue( + event: Pick, + options: EventStepOptions, +): number { + const { step, largeStep, smallStep } = options + const isArrowKey = arrowKeys.has(event.key) + if (smallStep != null && event.altKey && isArrowKey) { + return smallStep } + const isLargeStep = pageKeys.has(event.key) || (event.shiftKey && isArrowKey) + return isLargeStep ? largeStep : step } export function getEventPoint(event: any, type: "page" | "client" = "client"): { x: number; y: number } { diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 28e2be7601..e3378e3f13 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -91,6 +91,8 @@ export const numberInputControls = defineControls({ allowMouseWheel: { type: "boolean", defaultValue: false }, spinOnPress: { type: "boolean", defaultValue: true }, step: { type: "number", defaultValue: 1 }, + largeStep: { type: "number" }, + smallStep: { type: "number" }, min: { type: "number", defaultValue: 0 }, max: { type: "number", defaultValue: 100 }, locale: { @@ -141,6 +143,7 @@ export const sliderControls = defineControls({ min: { type: "number", defaultValue: 0 }, max: { type: "number", defaultValue: 100 }, step: { type: "number", defaultValue: 1 }, + largeStep: { type: "number" }, }) export const tabsControls = defineControls({ diff --git a/website/demos/index.tsx b/website/demos/index.tsx index a97f4c633a..6d1d4d9138 100644 --- a/website/demos/index.tsx +++ b/website/demos/index.tsx @@ -509,7 +509,7 @@ const components = { invalid: false, granularity: { default: "day", - options: ["day", "month", "year", "hour", "minute", "second"], + options: ["day", "hour", "minute", "second"], }, selectionMode: { default: "single", options: ["single", "range"] }, shouldForceLeadingZeros: false,