diff --git a/packages/main/cypress/specs/ColorPicker.cy.tsx b/packages/main/cypress/specs/ColorPicker.cy.tsx index 4a7b42b8173a..09079011e3c5 100644 --- a/packages/main/cypress/specs/ColorPicker.cy.tsx +++ b/packages/main/cypress/specs/ColorPicker.cy.tsx @@ -306,6 +306,68 @@ describe("Color Picker general interaction tests", () => { }); }); +describe("Color Picker font-size scaling (regression for #13521)", () => { + // The picker box is sized in rem, so it scales with root font-size. The pointer + // math used to assume 16rem === 256px and broke at any other root font-size. + // afterEach runs even on assertion failure, so the document state is always restored. + afterEach(() => { + cy.document().then(doc => { + doc.documentElement.style.fontSize = ""; + }); + }); + + it("should select white at bottom-right corner with 14px root font-size", () => { + cy.document().then(doc => { + doc.documentElement.style.fontSize = "14px"; + }); + + cy.mount(); + + cy.get("[ui5-color-picker]").as("colorPicker"); + + // Use position to avoid hardcoding pixel coordinates that depend on font-size. + cy.get("@colorPicker") + .shadow() + .find(".ui5-color-picker-main-color") + .realClick({ position: "bottomRight" }); + + cy.get("@colorPicker") + .ui5ColorPickerToggleColorMode(); + + // Bottom-right corner = white => saturation 0%, lightness 100%. + cy.get("@colorPicker") + .ui5ColorPickerValidateInput("#saturation", "0"); + + cy.get("@colorPicker") + .ui5ColorPickerValidateInput("#light", "100"); + }); + + it("should select black at top-left corner with 20px root font-size", () => { + cy.document().then(doc => { + doc.documentElement.style.fontSize = "20px"; + }); + + cy.mount(); + + cy.get("[ui5-color-picker]").as("colorPicker"); + + cy.get("@colorPicker") + .shadow() + .find(".ui5-color-picker-main-color") + .realClick({ position: "topLeft" }); + + cy.get("@colorPicker") + .ui5ColorPickerToggleColorMode(); + + // Top-left corner = full saturation, zero lightness (black at full saturation). + cy.get("@colorPicker") + .ui5ColorPickerValidateInput("#saturation", "100"); + + cy.get("@colorPicker") + .ui5ColorPickerValidateInput("#light", "0"); + }); +}); + describe("Color Picker accessibility tests", () => { it("should show correct accessibility info for RGB inputs", () => { cy.mount(); diff --git a/packages/main/src/ColorPicker.ts b/packages/main/src/ColorPicker.ts index f9d4fa461ef2..b82e1dadae74 100644 --- a/packages/main/src/ColorPicker.ts +++ b/packages/main/src/ColorPicker.ts @@ -1,6 +1,7 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import query from "@ui5/webcomponents-base/dist/decorators/query.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import { isEnter } from "@ui5/webcomponents-base/dist/Keys.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; @@ -47,7 +48,9 @@ import { import ColorPickerCss from "./generated/themes/ColorPicker.css.js"; import type { UI5CustomEvent } from "@ui5/webcomponents-base/dist/index.js"; -const PICKER_POINTER_WIDTH = 6.5; +// Fallback box width in CSS pixels at 16px root font-size (16rem). +// Used when the picker box hasn't been measured yet (rare — only if the box ref is missing). +const DEFAULT_BOX_SIZE = 256; type ColorCoordinates = { x: number, @@ -224,6 +227,9 @@ class ColorPicker extends UI5Element implements IFormInputElement { mouseIn: boolean; + @query(".ui5-color-picker-main-color") + _mainColorRef?: HTMLElement; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -239,10 +245,11 @@ class ColorPicker extends UI5Element implements IFormInputElement { super(); this._colorValue = new ColorValue(); - // Bottom Right corner + // Bottom-right corner of the picker box (white = l=100%, s=0%) + // Stored as percentages so positioning is independent of root font-size. this._selectedCoordinates = { - x: 256 - PICKER_POINTER_WIDTH, - y: 256 - PICKER_POINTER_WIDTH, + x: 100, + y: 100, }; // Default main color is red @@ -258,6 +265,12 @@ class ColorPicker extends UI5Element implements IFormInputElement { this.mouseIn = false; } + get _boxSize(): number { + // clientWidth excludes border, matching the coordinate space of MouseEvent.offsetX/Y + // which is measured from the element's padding edge. + return this._mainColorRef?.clientWidth || DEFAULT_BOX_SIZE; + } + onBeforeRendering() { const valueAsRGB = getRGBColor(this.value); if (!this._isColorValueEqual(valueAsRGB)) { @@ -495,9 +508,12 @@ class ColorPicker extends UI5Element implements IFormInputElement { } _changeSelectedColor(x: number, y: number) { + const boxSize = this._boxSize; + // Store coordinates as percentages of the picker box; the template uses these as + // `left: x%` / `top: y%` so positioning is independent of root font-size. this._selectedCoordinates = { - x: x - PICKER_POINTER_WIDTH, // Center the coordinates, because of the width of the circle - y: y - PICKER_POINTER_WIDTH, // Center the coordinates, because of the height of the circle + x: (x / boxSize) * 100, + y: (y / boxSize) * 100, }; // Idication that changes to the color settings are triggered as a result of user pressing over the main color section. @@ -524,8 +540,9 @@ class ColorPicker extends UI5Element implements IFormInputElement { // 0 ≤ H < 360 // 4.251 because with 4.25 we get out of the colors range. const h = this._hue; - let s = +(1 - (y / 256)).toFixed(2); - let l = +(x / 256).toFixed(2); + const boxSize = this._boxSize; + let s = +(1 - (y / boxSize)).toFixed(2); + let l = +(x / boxSize).toFixed(2); if (Number.isNaN(s) || Number.isNaN(l)) { // The event is finished out of the main color section @@ -551,9 +568,11 @@ class ColorPicker extends UI5Element implements IFormInputElement { _updateColorGrid() { const hslColours: ColorHSL = this._colorValue.HSL; + // Coordinates are percentages: x = lightness, y = inverted saturation. + // The template applies them as `left: x%` / `top: y%` so the circle scales with the box. this._selectedCoordinates = { - x: ((hslColours.l * 2.56)) - PICKER_POINTER_WIDTH, // Center the coordinates, because of the width of the circle - y: (256 - (hslColours.s * 2.56)) - PICKER_POINTER_WIDTH, // Center the coordinates, because of the height of the circle + x: hslColours.l, + y: 100 - hslColours.s, }; if (this._isSelectedColorChanged) { // We shouldn't update the hue value when user presses over the main color section. diff --git a/packages/main/src/ColorPickerTemplate.tsx b/packages/main/src/ColorPickerTemplate.tsx index 4a31c5ffa4ea..3a99823a7332 100644 --- a/packages/main/src/ColorPickerTemplate.tsx +++ b/packages/main/src/ColorPickerTemplate.tsx @@ -23,8 +23,8 @@ export default function ColorPickerTemplate(this: ColorPicker) {
diff --git a/packages/main/src/themes/ColorPicker.css b/packages/main/src/themes/ColorPicker.css index 7f381f48fb94..fec2b3caa788 100644 --- a/packages/main/src/themes/ColorPicker.css +++ b/packages/main/src/themes/ColorPicker.css @@ -36,6 +36,8 @@ border: var(--_ui5_color_picker_circle_outer_border); border-radius: 0.6875rem; pointer-events: none; + /* Inline left/top are percentages of the picker box; translate centers the circle on that point. */ + transform: translate(-50%, -50%); } .ui5-color-picker-circle:after { diff --git a/packages/main/test/pages/ColorPicker.html b/packages/main/test/pages/ColorPicker.html index 487078daf6ce..79a941d8002a 100644 --- a/packages/main/test/pages/ColorPicker.html +++ b/packages/main/test/pages/ColorPicker.html @@ -24,6 +24,71 @@ +
+ Testing controls + + + + + + + + + + + + +
+ +

pointer must stay aligned at any root font-size

+

+ Set a non-16px root font-size from the Testing controls above. Click each corner of the + saturation/lightness box on the pickers below — the pointer should land exactly under the + cursor, and clicking the bottom-right should yield #ffffff. +

+ +
+
+

Default

+ +
value: —
+
+ +
+

Pre-set white (bottom-right corner)

+ +
value: —
+
+ +
+

Pre-set HSL center (50%, 50%)

+ +
value: —
+
+
+ +

General playground

+ @@ -76,5 +141,58 @@ colorPickerHSL._displayHSL = true; + + diff --git a/packages/main/test/pages/styles/ColorPicker.css b/packages/main/test/pages/styles/ColorPicker.css index d67ab891f55a..3c109b23e602 100644 --- a/packages/main/test/pages/styles/ColorPicker.css +++ b/packages/main/test/pages/styles/ColorPicker.css @@ -1,3 +1,73 @@ .colorpicker1auto { background-color: var(--sapBackgroundColor); } + +.cp-test-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + background: var(--sapList_Background); + border: 1px solid var(--sapList_BorderColor); + border-radius: 0.25rem; + position: sticky; + top: 0; + z-index: 10; +} + +.cp-test-controls strong { + margin-right: 0.5rem; +} + +.cp-test-controls label { + font-weight: bold; +} + +.cp-test-controls #cpComputedSize { + font-family: monospace; + color: var(--sapTextColor); + opacity: 0.75; +} + +.cp-test-controls #cpReset { + margin-left: auto; +} + +.cp-issue-instructions { + margin: 0 0 1rem; + padding: 0.5rem 0.75rem; + background: var(--sapInfobar_Background, #f0f7ff); + border-left: 4px solid var(--sapInformativeColor, #0a6ed1); + font-size: 0.875rem; +} + +.cp-issue-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.cp-issue-card { + padding: 0.75rem; + border: 1px solid var(--sapList_BorderColor); + border-radius: 0.25rem; + background: var(--sapList_Background); +} + +.cp-issue-card h3 { + margin: 0 0 0.5rem; + font-size: 1rem; +} + +.cp-readout { + margin-top: 0.5rem; + font-family: monospace; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background: var(--sapShellColor, #f5f5f5); + border-radius: 0.25rem; + min-height: 1.25rem; +}