From f71bd89852dcb8988b4f081e13a32a68ad1031e9 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 28 Jun 2026 17:26:20 +0200 Subject: [PATCH 1/2] feat(slider, number-input): add configurable largeStep and smallStep Fix Cmd/Ctrl + arrow producing values off the step grid (e.g. non-integer values when step is 1). The implicit modifier-based stepping is replaced with explicit, configurable props: - slider: `largeStep` (defaults to `10 * step`) on Shift / PageUp / PageDown - number-input: `largeStep` (defaults to `10 * step`, on Shift) and `smallStep` (defaults to `step / 10`, on Alt) Defaults preserve the previous stepping magnitudes, so existing behavior is unchanged unless the new props are set. Closes #3195 --- .../slider-number-input-step-modifiers.md | 15 +++++++ e2e/number-input.e2e.ts | 31 +++++++++++---- e2e/slider.e2e.ts | 31 +++++++++++++++ packages/docs/data/accessibility.json | 24 ++++++++---- packages/docs/data/api.json | 15 +++++++ .../number-input/src/number-input.connect.ts | 8 +++- .../number-input/src/number-input.machine.ts | 2 + .../number-input/src/number-input.props.ts | 2 + .../number-input/src/number-input.types.ts | 14 ++++++- .../machines/slider/src/slider.connect.ts | 4 +- .../machines/slider/src/slider.machine.ts | 1 + packages/machines/slider/src/slider.props.ts | 1 + packages/machines/slider/src/slider.types.ts | 7 ++++ packages/utilities/dom-query/src/event.ts | 39 +++++++++++++++---- shared/src/controls.ts | 3 ++ website/demos/index.tsx | 2 +- 16 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 .changeset/slider-number-input-step-modifiers.md 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/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, From 48cb14f0c5bd11e1cf89022480e873b05d97a78c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 28 Jun 2026 18:10:00 +0200 Subject: [PATCH 2/2] ci: pin Node to 24.16.0 to avoid node-fetch premature-close in release Node 24.17.0 introduced a regression (nodejs/node#63989) where node-fetch@2 throws ERR_STREAM_PREMATURE_CLOSE on reused keep-alive sockets. This intermittently fails the Publish workflow during changelog generation, since @changesets/get-github-info@0.8.0 uses node-fetch@2 to call the GitHub GraphQL API. 24.16.0 is the last release before the regression. Revert once Node ships a fix or get-github-info moves off node-fetch@2. --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index a45fd52cc5..b832e4001d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +24.16.0