Skip to content

Commit 8ff341a

Browse files
committed
fix: angle slider dragging
1 parent e6bf9e5 commit 8ff341a

File tree

8 files changed

+186
-56
lines changed

8 files changed

+186
-56
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@zag-js/angle-slider": patch
3+
---
4+
5+
Fix issue where clicking and dragging the angle-slider thumb from a non-center position causes unexpected value jumps.
6+
The thumb now maintains its relative position from the initial click point throughout the drag operation, providing more
7+
intuitive dragging behavior.

e2e/angle-slider.e2e.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from "@playwright/test"
1+
import { expect, test } from "@playwright/test"
22
import { AngleSliderModel } from "./models/angle-slider.model"
33

44
let I: AngleSliderModel
@@ -12,4 +12,50 @@ test.describe("angle-slider", () => {
1212
test("should have no accessibility violation", async () => {
1313
await I.checkAccessibility()
1414
})
15+
16+
test("[pointer] should jump to clicked position on control", async () => {
17+
// Click directly on the control (not on thumb) should jump to that position
18+
await I.seeValueText("0deg")
19+
20+
// Click at 90 degrees position
21+
await I.clickControlAtAngle(90)
22+
23+
const result = await I.getCurrentValue()
24+
const resultAngle = parseInt(result.replace("deg", ""))
25+
26+
// Should be close to 90 degrees (allow some tolerance)
27+
expect(resultAngle).toBeGreaterThanOrEqual(85)
28+
expect(resultAngle).toBeLessThanOrEqual(95)
29+
})
30+
31+
test("[pointer] should maintain relative position when dragging from thumb edge", async () => {
32+
// Start from default position (0deg)
33+
await I.seeValueText("0deg")
34+
35+
// Drag thumb from its edge (not center) to 90 degrees
36+
await I.dragThumbFromEdgeToAngle(90)
37+
38+
const result = await I.getCurrentValue()
39+
const resultAngle = parseInt(result.replace("deg", ""))
40+
41+
// Should be close to 90 degrees despite clicking edge of thumb
42+
expect(resultAngle).toBeGreaterThanOrEqual(85)
43+
expect(resultAngle).toBeLessThanOrEqual(95)
44+
})
45+
46+
test("[pointer] should maintain offset throughout drag operation", async () => {
47+
// Set initial value to 45 degrees
48+
await I.clickControlAtAngle(45)
49+
await I.seeValueText("45deg")
50+
51+
// Now drag from thumb edge to 180 degrees
52+
await I.dragThumbFromEdgeToAngle(180)
53+
54+
const result = await I.getCurrentValue()
55+
const resultAngle = parseInt(result.replace("deg", ""))
56+
57+
// Should be close to 180 degrees
58+
expect(resultAngle).toBeGreaterThanOrEqual(175)
59+
expect(resultAngle).toBeLessThanOrEqual(185)
60+
})
1561
})

e2e/models/angle-slider.model.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type Page } from "@playwright/test"
2-
import { a11y } from "../_utils"
1+
import { expect, type Page } from "@playwright/test"
2+
import { a11y, rect } from "../_utils"
33
import { Model } from "./model"
44

55
export class AngleSliderModel extends Model {
@@ -30,4 +30,51 @@ export class AngleSliderModel extends Model {
3030
focusThumb() {
3131
return this.thumb.focus()
3232
}
33+
34+
async getCurrentValue(): Promise<string> {
35+
return (await this.output.textContent()) || "0deg"
36+
}
37+
38+
async seeValueText(value: string) {
39+
return expect(this.output).toHaveText(value)
40+
}
41+
42+
async clickControlAtAngle(angle: number) {
43+
const controlBbox = await rect(this.control)
44+
const centerX = controlBbox.x + controlBbox.width / 2
45+
const centerY = controlBbox.y + controlBbox.height / 2
46+
const radius = (Math.min(controlBbox.width, controlBbox.height) / 2) * 0.8
47+
48+
// Convert angle to radians (0° is at top, clockwise)
49+
const radians = ((angle - 90) * Math.PI) / 180
50+
const targetX = centerX + radius * Math.cos(radians)
51+
const targetY = centerY + radius * Math.sin(radians)
52+
53+
await this.page.mouse.click(targetX, targetY)
54+
}
55+
56+
async dragThumbFromEdgeToAngle(targetAngle: number) {
57+
// Get thumb and control positions
58+
const thumbBbox = await rect(this.thumb)
59+
const controlBbox = await rect(this.control)
60+
61+
// Click at edge of thumb (not center) to test relative dragging
62+
const thumbEdgeX = thumbBbox.x + thumbBbox.width * 0.8
63+
const thumbEdgeY = thumbBbox.y + thumbBbox.height * 0.2
64+
65+
// Calculate target position for the desired angle
66+
const centerX = controlBbox.x + controlBbox.width / 2
67+
const centerY = controlBbox.y + controlBbox.height / 2
68+
const radius = (Math.min(controlBbox.width, controlBbox.height) / 2) * 0.8
69+
70+
const radians = ((targetAngle - 90) * Math.PI) / 180
71+
const targetX = centerX + radius * Math.cos(radians)
72+
const targetY = centerY + radius * Math.sin(radians)
73+
74+
// Drag from thumb edge to target position
75+
await this.page.mouse.move(thumbEdgeX, thumbEdgeY)
76+
await this.page.mouse.down()
77+
await this.page.mouse.move(targetX, targetY)
78+
await this.page.mouse.up()
79+
}
3380
}

packages/machines/angle-slider/src/angle-slider.connect.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { dataAttr, getEventPoint, getEventStep, isLeftClick } from "@zag-js/dom-query"
1+
import { dataAttr, getEventPoint, getEventStep, getNativeEvent, isLeftClick } from "@zag-js/dom-query"
22
import type { NormalizeProps, PropTypes } from "@zag-js/types"
33
import { parts } from "./angle-slider.anatomy"
44
import * as dom from "./angle-slider.dom"
5-
import type { AngleSliderService, AngleSliderApi } from "./angle-slider.types"
5+
import type { AngleSliderApi, AngleSliderService } from "./angle-slider.types"
6+
import { getAngle } from "./angle-slider.utils"
67

78
export function connect<T extends PropTypes>(
89
service: AngleSliderService,
@@ -81,8 +82,22 @@ export function connect<T extends PropTypes>(
8182
onPointerDown(event) {
8283
if (!interactive) return
8384
if (!isLeftClick(event)) return
85+
8486
const point = getEventPoint(event)
85-
send({ type: "CONTROL.POINTER_DOWN", point })
87+
const controlEl = event.currentTarget
88+
89+
// Check if pointer is over the thumb (if thumb exists)
90+
const thumbEl = dom.getThumbEl(scope)
91+
const composedPath = getNativeEvent(event).composedPath()
92+
const isOverThumb = thumbEl && composedPath.includes(thumbEl)
93+
94+
let angularOffset = null
95+
if (isOverThumb) {
96+
const clickAngle = getAngle(controlEl, point)
97+
angularOffset = clickAngle - value
98+
}
99+
100+
send({ type: "CONTROL.POINTER_DOWN", point, angularOffset })
86101
event.stopPropagation()
87102
},
88103
style: {

packages/machines/angle-slider/src/angle-slider.machine.ts

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { raf, setElementValue, trackPointerMove } from "@zag-js/dom-query"
2-
import { createRect, getPointAngle } from "@zag-js/rect-utils"
3-
import type { Point } from "@zag-js/types"
4-
import { snapValueToStep } from "@zag-js/utils"
52
import { createMachine } from "@zag-js/core"
63
import * as dom from "./angle-slider.dom"
74
import type { AngleSliderSchema } from "./angle-slider.types"
8-
9-
const MIN_VALUE = 0
10-
const MAX_VALUE = 359
5+
import { clampAngle, constrainAngle, getAngle, MIN_VALUE, MAX_VALUE, snapAngleToStep } from "./angle-slider.utils"
116

127
export const machine = createMachine<AngleSliderSchema>({
138
props({ props }) {
@@ -30,6 +25,12 @@ export const machine = createMachine<AngleSliderSchema>({
3025
}
3126
},
3227

28+
refs() {
29+
return {
30+
thumbDragOffset: null,
31+
}
32+
},
33+
3334
computed: {
3435
interactive: ({ prop }) => !(prop("disabled") || prop("readOnly")),
3536
valueAsDegree: ({ context }) => `${context.get("value")}deg`,
@@ -56,7 +57,7 @@ export const machine = createMachine<AngleSliderSchema>({
5657
on: {
5758
"CONTROL.POINTER_DOWN": {
5859
target: "dragging",
59-
actions: ["setPointerValue", "focusThumb"],
60+
actions: ["setThumbDragOffset", "setPointerValue", "focusThumb"],
6061
},
6162
"THUMB.FOCUS": {
6263
target: "focused",
@@ -68,7 +69,7 @@ export const machine = createMachine<AngleSliderSchema>({
6869
on: {
6970
"CONTROL.POINTER_DOWN": {
7071
target: "dragging",
71-
actions: ["setPointerValue", "focusThumb"],
72+
actions: ["setThumbDragOffset", "setPointerValue", "focusThumb"],
7273
},
7374
"THUMB.ARROW_DEC": {
7475
actions: ["decrementValue", "invokeOnChangeEnd"],
@@ -94,7 +95,7 @@ export const machine = createMachine<AngleSliderSchema>({
9495
on: {
9596
"DOC.POINTER_UP": {
9697
target: "focused",
97-
actions: ["invokeOnChangeEnd"],
98+
actions: ["invokeOnChangeEnd", "clearThumbDragOffset"],
9899
},
99100
"DOC.POINTER_MOVE": {
100101
actions: ["setPointerValue"],
@@ -128,10 +129,11 @@ export const machine = createMachine<AngleSliderSchema>({
128129
valueAsDegree: computed("valueAsDegree"),
129130
})
130131
},
131-
setPointerValue({ scope, event, context, prop }) {
132+
setPointerValue({ scope, event, context, prop, refs }) {
132133
const controlEl = dom.getControlEl(scope)
133134
if (!controlEl) return
134-
const deg = getAngle(controlEl, event.point)
135+
const angularOffset = refs.get("thumbDragOffset")
136+
const deg = getAngle(controlEl, event.point, angularOffset)
135137
context.set("value", constrainAngle(deg, prop("step")))
136138
},
137139
setValueToMin({ context }) {
@@ -144,48 +146,24 @@ export const machine = createMachine<AngleSliderSchema>({
144146
context.set("value", clampAngle(event.value))
145147
},
146148
decrementValue({ context, event, prop }) {
147-
const value = snapValueToStep(
148-
context.get("value") - event.step,
149-
MIN_VALUE,
150-
MAX_VALUE,
151-
event.step ?? prop("step"),
152-
)
149+
const value = snapAngleToStep(context.get("value") - event.step, event.step ?? prop("step"))
153150
context.set("value", value)
154151
},
155152
incrementValue({ context, event, prop }) {
156-
const value = snapValueToStep(
157-
context.get("value") + event.step,
158-
MIN_VALUE,
159-
MAX_VALUE,
160-
event.step ?? prop("step"),
161-
)
153+
const value = snapAngleToStep(context.get("value") + event.step, event.step ?? prop("step"))
162154
context.set("value", value)
163155
},
164156
focusThumb({ scope }) {
165157
raf(() => {
166158
dom.getThumbEl(scope)?.focus({ preventScroll: true })
167159
})
168160
},
161+
setThumbDragOffset({ refs, event }) {
162+
refs.set("thumbDragOffset", event.angularOffset ?? null)
163+
},
164+
clearThumbDragOffset({ refs }) {
165+
refs.set("thumbDragOffset", null)
166+
},
169167
},
170168
},
171169
})
172-
173-
function getAngle(controlEl: HTMLElement, point: Point) {
174-
const rect = createRect(controlEl.getBoundingClientRect())
175-
return getPointAngle(rect, point)
176-
}
177-
178-
function clampAngle(degree: number) {
179-
return Math.min(Math.max(degree, MIN_VALUE), MAX_VALUE)
180-
}
181-
182-
function constrainAngle(degree: number, step: number) {
183-
const clampedDegree = clampAngle(degree)
184-
const upperStep = Math.ceil(clampedDegree / step)
185-
const nearestStep = Math.round(clampedDegree / step)
186-
return upperStep >= clampedDegree / step
187-
? upperStep * step === MAX_VALUE
188-
? MIN_VALUE
189-
: upperStep * step
190-
: nearestStep * step
191-
}

packages/machines/angle-slider/src/angle-slider.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export interface AngleSliderSchema {
9090
context: {
9191
value: number
9292
}
93+
refs: {
94+
thumbDragOffset: number | null
95+
}
9396
action: string
9497
event: EventObject
9598
effect: string
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createRect, getPointAngle } from "@zag-js/rect-utils"
2+
import type { Point } from "@zag-js/types"
3+
import { snapValueToStep } from "@zag-js/utils"
4+
5+
export const MIN_VALUE = 0
6+
export const MAX_VALUE = 359
7+
8+
export function getAngle(controlEl: HTMLElement, point: Point, angularOffset?: number | null) {
9+
const rect = createRect(controlEl.getBoundingClientRect())
10+
const angle = getPointAngle(rect, point)
11+
12+
// Apply angular offset for relative thumb dragging
13+
if (angularOffset != null) {
14+
return angle - angularOffset
15+
}
16+
17+
return angle
18+
}
19+
20+
export function clampAngle(degree: number) {
21+
return Math.min(Math.max(degree, MIN_VALUE), MAX_VALUE)
22+
}
23+
24+
export function constrainAngle(degree: number, step: number) {
25+
const clampedDegree = clampAngle(degree)
26+
const upperStep = Math.ceil(clampedDegree / step)
27+
const nearestStep = Math.round(clampedDegree / step)
28+
return upperStep >= clampedDegree / step
29+
? upperStep * step === MAX_VALUE
30+
? MIN_VALUE
31+
: upperStep * step
32+
: nearestStep * step
33+
}
34+
35+
export function snapAngleToStep(value: number, step: number) {
36+
return snapValueToStep(value, MIN_VALUE, MAX_VALUE, step)
37+
}

shared/src/css/angle-slider.css

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
top: 0;
2525
right: 0;
2626
bottom: 0;
27-
left: calc(50% - 1.5px);
28-
pointer-events: none;
27+
--thumb-width: 3px;
28+
left: calc(50% - var(--thumb-width) / 2);
2929
height: 100%;
30-
width: 3px;
30+
width: var(--thumb-width);
3131

3232
&::before {
3333
content: "";
@@ -36,7 +36,7 @@
3636
top: 0;
3737
height: var(--thumb-indicator-size);
3838
background: red;
39-
width: 3px;
39+
width: var(--thumb-width);
4040
}
4141
}
4242

@@ -81,6 +81,3 @@
8181
--marker-color: lightgray;
8282
}
8383
}
84-
85-
[data-scope="angle-slider"][data-part="value-text"] {
86-
}

0 commit comments

Comments
 (0)