Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/slider-number-input-step-modifiers.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24
24.16.0
31 changes: 23 additions & 8 deletions e2e/number-input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
31 changes: 31 additions & 0 deletions e2e/slider.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 16 additions & 8 deletions packages/docs/data/accessibility.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,19 +335,19 @@
},
{
"keys": ["PageUp"],
"description": "<span>Increases the value by a larger step</span>"
"description": "<span>Increases the value by the <code>largeStep</code> amount.</span>"
},
{
"keys": ["PageDown"],
"description": "<span>Decreases the value by a larger step</span>"
"description": "<span>Decreases the value by the <code>largeStep</code> amount.</span>"
},
{
"keys": ["Shift + ArrowUp"],
"description": "<span>Increases the value by a larger step</span>"
"description": "<span>Increases the value by the <code>largeStep</code> amount.</span>"
},
{
"keys": ["Shift + ArrowDown"],
"description": "<span>Decreases the value by a larger step</span>"
"description": "<span>Decreases the value by the <code>largeStep</code> amount.</span>"
},
{
"keys": ["Home"],
Expand Down Expand Up @@ -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"],
Expand Down
15 changes: 15 additions & 0 deletions packages/docs/data/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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`",
Expand Down
8 changes: 6 additions & 2 deletions packages/machines/number-input/src/number-input.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
ariaAttr,
dataAttr,
getEventPoint,
getEventStep,
getEventStepValue,
getWindow,
isComposingEvent,
isLeftClick,
Expand Down Expand Up @@ -177,7 +177,11 @@ export function connect<T extends PropTypes>(
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<HTMLInputElement> = {
ArrowUp() {
Expand Down
2 changes: 2 additions & 0 deletions packages/machines/number-input/src/number-input.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/machines/number-input/src/number-input.props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const props = createProps<NumberInputProps>()([
"ids",
"inputMode",
"invalid",
"largeStep",
"locale",
"max",
"min",
Expand All @@ -27,6 +28,7 @@ export const props = createProps<NumberInputProps>()([
"pattern",
"required",
"readOnly",
"smallStep",
"spinOnPress",
"step",
"translations",
Expand Down
14 changes: 12 additions & 2 deletions packages/machines/number-input/src/number-input.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -177,11 +187,11 @@ type PropsWithDefault =
| "pattern"
| "translations"
| "step"
| "largeStep"
| "smallStep"
| "spinOnPress"
| "min"
| "max"
| "step"
| "translations"

type ComputedContext = Readonly<{
/**
Expand Down
4 changes: 2 additions & 2 deletions packages/machines/slider/src/slider.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
dataAttr,
getEventKey,
getEventPoint,
getEventStep,
getEventStepValue,
isLeftClick,
isModifierKey,
} from "@zag-js/dom-query"
Expand Down Expand Up @@ -213,7 +213,7 @@ export function connect<T extends PropTypes>(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() {
Expand Down
1 change: 1 addition & 0 deletions packages/machines/slider/src/slider.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const machine = createMachine<SliderSchema>({
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,
Expand Down
1 change: 1 addition & 0 deletions packages/machines/slider/src/slider.props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const props = createProps<SliderProps>()([
"id",
"ids",
"invalid",
"largeStep",
"max",
"min",
"minStepsBetweenThumbs",
Expand Down
7 changes: 7 additions & 0 deletions packages/machines/slider/src/slider.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -166,6 +172,7 @@ type PropsWithDefault =
| "min"
| "max"
| "step"
| "largeStep"
| "orientation"
| "defaultValue"
| "origin"
Expand Down
39 changes: 32 additions & 7 deletions packages/utilities/dom-query/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,39 @@ export function getNativeEvent<E>(event: E): NativeEvent<E> {
const pageKeys = new Set(["PageUp", "PageDown"])
const arrowKeys = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"])

export function getEventStep(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "key" | "shiftKey">) {
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<KeyboardEvent, "key" | "shiftKey">) {
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<KeyboardEvent, "key" | "shiftKey" | "altKey">,
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 } {
Expand Down
3 changes: 3 additions & 0 deletions shared/src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion website/demos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading