Skip to content

Commit 2f37854

Browse files
authored
fix(color-picker): draw slider thumbs within bounds (#7398)
**Related Issue:** #7005 ## Summary This addresses slider thumbs appearing cut off. This also ensures the max hue slider value doesn't overlap with 0 and adds a util to map a value between ranges.
1 parent 98c870e commit 2f37854

File tree

5 files changed

+157
-89
lines changed

5 files changed

+157
-89
lines changed

packages/calcite-components/src/components/color-picker/color-picker.e2e.ts

Lines changed: 81 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,12 @@ describe("calcite-color-picker", () => {
519519
const expectedColorSamples = [
520520
"#ff0000",
521521
"#ffd900",
522-
"#48ff00",
523-
"#00ff91",
522+
"#4cff00",
523+
"#00ff8c",
524524
"#0095ff",
525-
"#4800ff",
526-
"#ff00dd",
527-
"#ff0004",
525+
"#4400ff",
526+
"#ff00e1",
527+
"#ff0008",
528528
];
529529

530530
for (let i = 0; i < expectedColorSamples.length; i++) {
@@ -678,7 +678,7 @@ describe("calcite-color-picker", () => {
678678
[hueScopeX, hueScopeY] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueScope}`);
679679
[hueScopeCenterX, hueScopeCenterY] = getScopeCenter(hueScopeX, hueScopeY);
680680

681-
expect(hueScopeCenterX).toBe(hueSliderX);
681+
expect(hueScopeCenterX).toBe(hueSliderX + DIMENSIONS.m.thumb.radius);
682682

683683
await page.mouse.move(hueScopeCenterX, hueScopeCenterY);
684684
await page.mouse.down();
@@ -689,7 +689,7 @@ describe("calcite-color-picker", () => {
689689
[hueScopeX] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueScope}`);
690690
[hueScopeCenterX] = getScopeCenter(hueScopeX, hueScopeY);
691691

692-
expect(hueScopeCenterX).toBe(hueSliderX + DIMENSIONS.m.slider.width - 1);
692+
expect(hueScopeCenterX).toBe(hueSliderX + DIMENSIONS.m.slider.width - DIMENSIONS.m.thumb.radius);
693693
});
694694

695695
describe("unsupported value handling", () => {
@@ -2197,23 +2197,23 @@ describe("calcite-color-picker", () => {
21972197

21982198
const getScopeLeftOffset = async () => parseFloat((await scope.getComputedStyle()).left);
21992199

2200-
expect(await getScopeLeftOffset()).toBe(-0.5);
2200+
expect(await getScopeLeftOffset()).toBeCloseTo(DIMENSIONS.m.thumb.radius - 0.5, 0);
22012201

22022202
await nudgeAQuarterOfSlider();
2203-
expect(await getScopeLeftOffset()).toBe(67.5);
2203+
expect(await getScopeLeftOffset()).toBeCloseTo(67.5, 0);
22042204

22052205
await nudgeAQuarterOfSlider();
2206-
expect(await getScopeLeftOffset()).toBe(135.5);
2206+
expect(await getScopeLeftOffset()).toBeCloseTo(135.5, 0);
22072207

22082208
await nudgeAQuarterOfSlider();
22092209
// hue wraps around, so we nudge it back to assert position at the edge
22102210
await scope.press("ArrowLeft");
2211-
expect(await getScopeLeftOffset()).toBeLessThanOrEqual(203.5);
2212-
expect(await getScopeLeftOffset()).toBeGreaterThan(202.5);
2211+
expect(await getScopeLeftOffset()).toBeLessThanOrEqual(193.5);
2212+
expect(await getScopeLeftOffset()).toBeGreaterThan(189.5);
22132213

22142214
// nudge it to wrap around
22152215
await scope.press("ArrowRight");
2216-
expect(await getScopeLeftOffset()).toBe(-0.5);
2216+
expect(await getScopeLeftOffset()).toBeCloseTo(DIMENSIONS.m.thumb.radius - 0.5, 0);
22172217
});
22182218

22192219
it("allows editing hue slider via keyboard", async () => {
@@ -2245,84 +2245,89 @@ describe("calcite-color-picker", () => {
22452245

22462246
expect(await hueSliderScope.getComputedStyle()).toMatchObject({
22472247
top: "9.5px",
2248-
left: "-0.5px",
2248+
left: `${DIMENSIONS.m.thumb.radius - 0.5}px`,
22492249
});
22502250
});
2251-
});
2252-
describe("mouse", () => {
2253-
const scopeSizeOffset = 0.8;
2254-
it("should update value when color field scope is moved", async () => {
2255-
const page = await newE2EPage();
2256-
await page.setContent(`<calcite-color-picker ></calcite-color-picker>`);
2257-
const colorPicker = await page.find("calcite-color-picker");
22582251

2259-
const [colorFieldScopeX, colorFieldScopeY] = await getElementXY(
2260-
page,
2261-
"calcite-color-picker",
2262-
`.${CSS.colorFieldScope}`
2263-
);
2264-
const value = await colorPicker.getProperty("value");
2252+
describe("mouse", () => {
2253+
const scopeSizeOffset = 0.8;
2254+
it("should update value when color field scope is moved", async () => {
2255+
const page = await newE2EPage();
2256+
await page.setContent(`<calcite-color-picker ></calcite-color-picker>`);
2257+
const colorPicker = await page.find("calcite-color-picker");
2258+
2259+
const [colorFieldScopeX, colorFieldScopeY] = await getElementXY(
2260+
page,
2261+
"calcite-color-picker",
2262+
`.${CSS.colorFieldScope}`
2263+
);
2264+
const value = await colorPicker.getProperty("value");
22652265

2266-
await page.mouse.move(colorFieldScopeX, colorFieldScopeY + scopeSizeOffset);
2267-
await page.mouse.down();
2268-
await page.mouse.up();
2269-
await page.waitForChanges();
2270-
expect(await colorPicker.getProperty("value")).not.toBe(value);
2271-
});
2266+
await page.mouse.move(colorFieldScopeX, colorFieldScopeY + scopeSizeOffset);
2267+
await page.mouse.down();
2268+
await page.mouse.up();
2269+
await page.waitForChanges();
2270+
expect(await colorPicker.getProperty("value")).not.toBe(value);
2271+
});
22722272

2273-
it("should update value when hue scope is moved", async () => {
2274-
const page = await newE2EPage();
2275-
await page.setContent(`<calcite-color-picker ></calcite-color-picker>`);
2276-
const colorPicker = await page.find("calcite-color-picker");
2273+
it("should update value when hue scope is moved", async () => {
2274+
const page = await newE2EPage();
2275+
await page.setContent(`<calcite-color-picker></calcite-color-picker>`);
2276+
const colorPicker = await page.find("calcite-color-picker");
22772277

2278-
const [hueScopeX, hueScopeY] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueScope}`);
2279-
const value = await colorPicker.getProperty("value");
2278+
const [hueScopeX, hueScopeY] = await getElementXY(page, "calcite-color-picker", `.${CSS.hueScope}`);
2279+
const value = await colorPicker.getProperty("value");
22802280

2281-
await page.mouse.move(hueScopeX + scopeSizeOffset, hueScopeY);
2282-
await page.mouse.down();
2283-
await page.mouse.up();
2284-
await page.waitForChanges();
2285-
expect(await colorPicker.getProperty("value")).not.toBe(value);
2286-
});
2281+
await page.mouse.move(hueScopeX + scopeSizeOffset, hueScopeY);
2282+
await page.mouse.down();
2283+
await page.mouse.up();
2284+
await page.waitForChanges();
2285+
expect(await colorPicker.getProperty("value")).not.toBe(value);
2286+
});
22872287

2288-
it("should update value when opacity scope is moved", async () => {
2289-
const page = await newE2EPage();
2290-
await page.setContent(`<calcite-color-picker alpha-channel></calcite-color-picker>`);
2291-
const [opacityScopeX, opacityScopeY] = await getElementXY(page, "calcite-color-picker", `.${CSS.opacityScope}`);
2292-
const colorPicker = await page.find("calcite-color-picker");
2293-
const value = await colorPicker.getProperty("value");
2294-
2295-
await page.mouse.move(opacityScopeX - 2, opacityScopeY);
2296-
await page.mouse.down();
2297-
await page.mouse.up();
2298-
await page.waitForChanges();
2299-
expect(await colorPicker.getProperty("value")).not.toBe(value);
2288+
it("should update value when opacity scope is moved", async () => {
2289+
const page = await newE2EPage();
2290+
await page.setContent(`<calcite-color-picker alpha-channel></calcite-color-picker>`);
2291+
const [opacityScopeX, opacityScopeY] = await getElementXY(
2292+
page,
2293+
"calcite-color-picker",
2294+
`.${CSS.opacityScope}`
2295+
);
2296+
const colorPicker = await page.find("calcite-color-picker");
2297+
const value = await colorPicker.getProperty("value");
2298+
2299+
await page.mouse.move(opacityScopeX - 2, opacityScopeY);
2300+
await page.mouse.down();
2301+
await page.mouse.up();
2302+
await page.waitForChanges();
2303+
expect(await colorPicker.getProperty("value")).not.toBe(value);
2304+
});
23002305
});
2301-
});
23022306

2303-
describe("alpha channel", () => {
2304-
it("allows editing alpha value via keyboard", async () => {
2305-
const page = await newE2EPage();
2306-
await page.setContent(`<calcite-color-picker alpha-channel value="#ffffffff"></calcite-color-picker>`);
2307+
describe("alpha channel", () => {
2308+
it("allows editing alpha value via keyboard", async () => {
2309+
const page = await newE2EPage();
2310+
await page.setContent(`<calcite-color-picker alpha-channel value="#ffffffff"></calcite-color-picker>`);
23072311

2308-
const picker = await page.find("calcite-color-picker");
2309-
const scope = await page.find(`calcite-color-picker >>> .${CSS.opacityScope}`);
2312+
const picker = await page.find("calcite-color-picker");
2313+
const scope = await page.find(`calcite-color-picker >>> .${CSS.opacityScope}`);
23102314

2311-
await scope.press("ArrowDown");
2312-
expect(await picker.getProperty("value")).toBe("#fffffffc");
2313-
await scope.press("ArrowDown");
2314-
expect(await picker.getProperty("value")).toBe("#fffffffa");
2315-
await scope.press("ArrowDown");
2316-
expect(await picker.getProperty("value")).toBe("#fffffff7");
2315+
await scope.press("ArrowDown");
2316+
expect(await picker.getProperty("value")).toBe("#fffffffc");
2317+
await scope.press("ArrowDown");
2318+
expect(await picker.getProperty("value")).toBe("#fffffffa");
2319+
await scope.press("ArrowDown");
2320+
expect(await picker.getProperty("value")).toBe("#fffffff7");
23172321

2318-
await scope.press("ArrowUp");
2319-
expect(await picker.getProperty("value")).toBe("#fffffffa");
2322+
await scope.press("ArrowUp");
2323+
expect(await picker.getProperty("value")).toBe("#fffffffa");
23202324

2321-
await scope.press("ArrowRight");
2322-
expect(await picker.getProperty("value")).toBe("#fffffffc");
2325+
await scope.press("ArrowRight");
2326+
expect(await picker.getProperty("value")).toBe("#fffffffc");
23232327

2324-
await scope.press("ArrowLeft");
2325-
expect(await picker.getProperty("value")).toBe("#fffffffa");
2328+
await scope.press("ArrowLeft");
2329+
expect(await picker.getProperty("value")).toBe("#fffffffa");
2330+
});
23262331
});
23272332
});
23282333
});

packages/calcite-components/src/components/color-picker/color-picker.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
DEFAULT_STORAGE_KEY_PREFIX,
2424
DIMENSIONS,
2525
HSV_LIMITS,
26+
HUE_LIMIT_CONSTRAINED,
2627
OPACITY_LIMITS,
2728
RGB_LIMITS,
2829
SCOPE_SIZE,
@@ -63,7 +64,7 @@ import {
6364
LocalizedComponent,
6465
NumberingSystem,
6566
} from "../../utils/locale";
66-
import { clamp } from "../../utils/math";
67+
import { clamp, closeToRangeEdge, remap } from "../../utils/math";
6768
import {
6869
connectMessages,
6970
disconnectMessages,
@@ -619,7 +620,7 @@ export class ColorPicker
619620
} else if (clientX < bounds.x) {
620621
samplingX = 0;
621622
} else {
622-
samplingX = bounds.width - 1;
623+
samplingX = bounds.width;
623624
}
624625

625626
if (clientY < bounds.y + bounds.height && clientY > bounds.y) {
@@ -1095,7 +1096,7 @@ export class ColorPicker
10951096
slider: { width },
10961097
},
10971098
} = this;
1098-
const hue = (360 / width) * x;
1099+
const hue = (HUE_LIMIT_CONSTRAINED / width) * x;
10991100

11001101
this.internalColorSet(this.baseColorFieldColor.hue(hue), false);
11011102
}
@@ -1385,7 +1386,6 @@ export class ColorPicker
13851386
const startAngle = 0;
13861387
const endAngle = 2 * Math.PI;
13871388
const outlineWidth = 1;
1388-
radius = radius - outlineWidth;
13891389

13901390
context.beginPath();
13911391
context.arc(x, y, radius, startAngle, endAngle);
@@ -1412,19 +1412,20 @@ export class ColorPicker
14121412

14131413
const {
14141414
dimensions: {
1415-
slider: { height, width },
1415+
slider: { width },
14161416
thumb: { radius },
14171417
},
14181418
} = this;
14191419

1420-
const x = hsvColor.hue() / (360 / width);
1421-
const y = radius - height / 2 + height / 2;
1420+
const x = hsvColor.hue() / (HUE_LIMIT_CONSTRAINED / width);
1421+
const y = radius;
1422+
const sliderBoundX = this.getSliderBoundX(x, width, radius);
14221423

14231424
requestAnimationFrame(() => {
1424-
this.hueScopeLeft = x;
1425+
this.hueScopeLeft = sliderBoundX;
14251426
});
14261427

1427-
this.drawThumb(this.hueSliderRenderingContext, radius, x, y, hsvColor);
1428+
this.drawThumb(this.hueSliderRenderingContext, radius, sliderBoundX, y, hsvColor);
14281429
}
14291430

14301431
private drawHueSlider(): void {
@@ -1441,7 +1442,15 @@ export class ColorPicker
14411442

14421443
const gradient = context.createLinearGradient(0, 0, width, 0);
14431444

1444-
const hueSliderColorStopKeywords = ["red", "yellow", "lime", "cyan", "blue", "magenta", "red"];
1445+
const hueSliderColorStopKeywords = [
1446+
"red",
1447+
"yellow",
1448+
"lime",
1449+
"cyan",
1450+
"blue",
1451+
"magenta",
1452+
"#ff0004" /* 1 unit less than #ff0 to avoid duplicate values within range */,
1453+
];
14451454

14461455
const offset = 1 / (hueSliderColorStopKeywords.length - 1);
14471456
let currentOffset = 0;
@@ -1565,12 +1574,23 @@ export class ColorPicker
15651574

15661575
const x = alphaToOpacity(hsvColor.alpha()) / (OPACITY_LIMITS.max / width);
15671576
const y = radius;
1577+
const sliderBoundX = this.getSliderBoundX(x, width, radius);
15681578

15691579
requestAnimationFrame(() => {
1570-
this.opacityScopeLeft = x;
1580+
this.opacityScopeLeft = sliderBoundX;
15711581
});
15721582

1573-
this.drawThumb(this.opacitySliderRenderingContext, radius, x, y, hsvColor);
1583+
this.drawThumb(this.opacitySliderRenderingContext, radius, sliderBoundX, y, hsvColor);
1584+
}
1585+
1586+
private getSliderBoundX(x: number, width: number, radius: number): number {
1587+
const closeToEdge = closeToRangeEdge(x, width, radius);
1588+
1589+
return closeToEdge === 0
1590+
? x
1591+
: closeToEdge === -1
1592+
? remap(x, 0, width, radius, radius * 2)
1593+
: remap(x, 0, width, width - radius * 2, width - radius);
15741594
}
15751595

15761596
private storeOpacityScope = (node: HTMLDivElement): void => {

packages/calcite-components/src/components/color-picker/resources.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const HSV_LIMITS = {
4848
v: 100,
4949
};
5050

51+
// 0 and 360 represent the same value, so we limit the hue to 359
52+
export const HUE_LIMIT_CONSTRAINED = HSV_LIMITS.h - 1;
53+
5154
export const OPACITY_LIMITS = {
5255
min: 0,
5356
max: 100,

packages/calcite-components/src/utils/math.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { clamp, decimalPlaces } from "./math";
1+
import { clamp, closeToRangeEdge, decimalPlaces, remap } from "./math";
22

33
describe("clamp", () => {
44
it("clamps numbers within min/max", () => {
@@ -14,3 +14,27 @@ describe("decimalPlaces", () => {
1414
expect(decimalPlaces(123.123)).toBe(3);
1515
});
1616
});
17+
18+
describe("remap", () => {
19+
it("remaps numbers", () => {
20+
expect(remap(5, 0, 10, 0, 100)).toBe(50);
21+
expect(remap(0, -100, 100, 0, 100)).toBe(50);
22+
expect(remap(0.5, 0, 1, 0, 100)).toBe(50);
23+
});
24+
});
25+
26+
describe("closeToRangeEdge", () => {
27+
it("returns -1 if close to lower edge", () => {
28+
expect(closeToRangeEdge(0, 100, 10)).toBe(-1);
29+
expect(closeToRangeEdge(9, 100, 10)).toBe(-1);
30+
});
31+
32+
it("returns 1 if close to upper edge", () => {
33+
expect(closeToRangeEdge(100, 100, 10)).toBe(1);
34+
expect(closeToRangeEdge(91, 100, 10)).toBe(1);
35+
});
36+
37+
it("returns 0 if not close to edge", () => {
38+
expect(closeToRangeEdge(50, 100, 10)).toBe(0);
39+
});
40+
});

packages/calcite-components/src/utils/math.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,19 @@ export const decimalPlaces = (value: number): number => {
1515
(match[2] ? +match[2] : 0)
1616
);
1717
};
18+
19+
export function remap(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number): number {
20+
return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin;
21+
}
22+
23+
/**
24+
* Helper to determine if a value is close to the edge of a range within a threshold.
25+
*
26+
* @param value
27+
* @param range
28+
* @param threshold
29+
* @returns -1 if close to lower edge, 1 if close to upper edge, 0 otherwise.
30+
*/
31+
export function closeToRangeEdge(value: number, range: number, threshold: number): number {
32+
return value < threshold ? -1 : value > range - threshold ? 1 : 0;
33+
}

0 commit comments

Comments
 (0)