Skip to content
Open
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
62 changes: 62 additions & 0 deletions packages/main/cypress/specs/ColorPicker.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ColorPicker></ColorPicker>);

cy.get("[ui5-color-picker]").as("colorPicker");

// Use position to avoid hardcoding pixel coordinates that depend on font-size.
cy.get<ColorPicker>("@colorPicker")
.shadow()
.find(".ui5-color-picker-main-color")
.realClick({ position: "bottomRight" });

cy.get<ColorPicker>("@colorPicker")
.ui5ColorPickerToggleColorMode();

// Bottom-right corner = white => saturation 0%, lightness 100%.
cy.get<ColorPicker>("@colorPicker")
.ui5ColorPickerValidateInput("#saturation", "0");

cy.get<ColorPicker>("@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(<ColorPicker></ColorPicker>);

cy.get("[ui5-color-picker]").as("colorPicker");

cy.get<ColorPicker>("@colorPicker")
.shadow()
.find(".ui5-color-picker-main-color")
.realClick({ position: "topLeft" });

cy.get<ColorPicker>("@colorPicker")
.ui5ColorPickerToggleColorMode();

// Top-left corner = full saturation, zero lightness (black at full saturation).
cy.get<ColorPicker>("@colorPicker")
.ui5ColorPickerValidateInput("#saturation", "100");

cy.get<ColorPicker>("@colorPicker")
.ui5ColorPickerValidateInput("#light", "0");
});
});

describe("Color Picker accessibility tests", () => {
it("should show correct accessibility info for RGB inputs", () => {
cy.mount(<ColorPicker></ColorPicker>);
Expand Down
39 changes: 29 additions & 10 deletions packages/main/src/ColorPicker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/main/src/ColorPickerTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export default function ColorPickerTemplate(this: ColorPicker) {
<div
class="ui5-color-picker-circle"
style={{
left: `${this._selectedCoordinates.x}px`,
top: `${this._selectedCoordinates.y}px`,
left: `${this._selectedCoordinates.x}%`,
top: `${this._selectedCoordinates.y}%`,
}}
></div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/main/src/themes/ColorPicker.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions packages/main/test/pages/ColorPicker.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,71 @@
</head>
<body class="colorpicker1auto">

<section class="cp-test-controls">
<strong>Testing controls</strong>

<label for="cpFontSize">Root font-size:</label>
<select id="cpFontSize">
<option value="">(default — 16px)</option>
<option value="10px">10px</option>
<option value="12px">12px</option>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
</select>

<label for="cpTheme">Theme:</label>
<select id="cpTheme">
<option value="sap_horizon">sap_horizon</option>
<option value="sap_horizon_dark">sap_horizon_dark</option>
<option value="sap_horizon_hcb">sap_horizon_hcb</option>
<option value="sap_horizon_hcw">sap_horizon_hcw</option>
<option value="sap_fiori_3">sap_fiori_3</option>
<option value="sap_fiori_3_dark">sap_fiori_3_dark</option>
<option value="sap_fiori_3_hcb">sap_fiori_3_hcb</option>
<option value="sap_fiori_3_hcw">sap_fiori_3_hcw</option>
</select>

<label>
<input type="checkbox" id="cpCompact"> Compact density
</label>

<span id="cpComputedSize"></span>

<button id="cpReset" type="button">Reset</button>
</section>

<h2>pointer must stay aligned at any root font-size</h2>
<p class="cp-issue-instructions">
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 <code>#ffffff</code>.
</p>

<div class="cp-issue-grid">
<div class="cp-issue-card">
<h3>Default</h3>
<ui5-color-picker id="cpDefault"></ui5-color-picker>
<div class="cp-readout" id="cpReadoutDefault">value: —</div>
</div>

<div class="cp-issue-card">
<h3>Pre-set white (bottom-right corner)</h3>
<ui5-color-picker id="cpWhite" value="rgba(255,255,255,1)"></ui5-color-picker>
<div class="cp-readout" id="cpReadoutWhite">value: —</div>
</div>

<div class="cp-issue-card">
<h3>Pre-set HSL center (50%, 50%)</h3>
<ui5-color-picker id="cpMid" value="hsl(180, 50%, 50%)"></ui5-color-picker>
<div class="cp-readout" id="cpReadoutMid">value: —</div>
</div>
</div>

<h2>General playground</h2>

<ui5-color-picker id="cp1"></ui5-color-picker>
<ui5-step-input id="changeEventCounter" value="0"></ui5-step-input>

Expand Down Expand Up @@ -76,5 +141,58 @@

colorPickerHSL._displayHSL = true;
</script>

<script>
// Testing controls — root font-size, theme, compact density.
var fontSizeSelect = document.getElementById("cpFontSize"),
themeSelect = document.getElementById("cpTheme"),
compactCheckbox = document.getElementById("cpCompact"),
computedSize = document.getElementById("cpComputedSize"),
resetBtn = document.getElementById("cpReset");

function updateComputedSize() {
computedSize.textContent = "(computed: " + getComputedStyle(document.documentElement).fontSize + ")";
}

fontSizeSelect.addEventListener("change", function (e) {
document.documentElement.style.fontSize = e.target.value;
updateComputedSize();
});

themeSelect.addEventListener("change", function (e) {
window["sap-ui-webcomponents-bundle"].configuration.setTheme(e.target.value);
});

compactCheckbox.addEventListener("change", function (e) {
document.body.classList.toggle("ui5-content-density-compact", e.target.checked);
});

resetBtn.addEventListener("click", function () {
document.documentElement.style.fontSize = "";
fontSizeSelect.value = "";
themeSelect.value = "sap_horizon";
window["sap-ui-webcomponents-bundle"].configuration.setTheme("sap_horizon");
compactCheckbox.checked = false;
document.body.classList.remove("ui5-content-density-compact");
updateComputedSize();
});

updateComputedSize();

["Default", "White", "Mid"].forEach(function (suffix) {
var picker = document.getElementById("cp" + suffix);
var readout = document.getElementById("cpReadout" + suffix);

function syncReadout() {
// _colorValue is private API, used only for diagnostic readout in this dev page.
var hsl = picker._colorValue && picker._colorValue.HSL;
var hslStr = hsl ? "H=" + Math.round(hsl.h) + " S=" + Math.round(hsl.s) + " L=" + Math.round(hsl.l) : "";
readout.textContent = "value: " + picker.value + (hslStr ? " | " + hslStr : "");
}

picker.addEventListener("change", syncReadout);
requestAnimationFrame(function () { requestAnimationFrame(syncReadout); });
});
</script>
</body>
</html>
70 changes: 70 additions & 0 deletions packages/main/test/pages/styles/ColorPicker.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading