diff --git a/packages/main/cypress/specs/Slider.cy.tsx b/packages/main/cypress/specs/Slider.cy.tsx
index 11354b83b934..860d478c8a5e 100644
--- a/packages/main/cypress/specs/Slider.cy.tsx
+++ b/packages/main/cypress/specs/Slider.cy.tsx
@@ -1058,3 +1058,172 @@ describe("Testing resize handling and RTL support", () => {
cy.get("@slider").should("have.value", 1);
});
});
+
+describe("Custom Values", () => {
+ const customTickmarks = [
+ { value: 0, label: "Freezing" },
+ { value: 25, label: "Room Temp" },
+ { value: 50, label: "Warm" },
+ { value: 100, label: "Boiling" },
+ ];
+
+ beforeEach(() => {
+ cy.get('[data-cy-root]')
+ .invoke('css', 'padding', '100px');
+ });
+
+ it("Renders custom labels on tickmarks", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .should("have.length", 4);
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .eq(0)
+ .should("have.text", "Freezing");
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .eq(3)
+ .should("have.text", "Boiling");
+ });
+
+ it("Snaps value to nearest tickmark on click", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]").as("slider");
+
+ // Click roughly in the middle should snap to 50 (Warm)
+ cy.get("@slider").realClick({ position: "center" });
+
+ cy.get("@slider").should("have.value", 50);
+ });
+
+ it("Arrow key navigates to next/previous custom value", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]").as("slider");
+ cy.get("@slider").realClick({ position: "left" });
+ cy.get("@slider").should("have.value", 0);
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 25);
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 50);
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 100);
+
+ // Should not go beyond max
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 100);
+
+ cy.get("@slider").realPress("ArrowLeft");
+ cy.get("@slider").should("have.value", 50);
+ });
+
+ it("Home/End keys jump to first/last custom value", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]").as("slider");
+ cy.get("@slider").realClick();
+
+ cy.get("@slider").realPress("Home");
+ cy.get("@slider").should("have.value", 0);
+
+ cy.get("@slider").realPress("End");
+ cy.get("@slider").should("have.value", 100);
+ });
+
+ it("Handle position is correct for non-uniform tickmark spacing", () => {
+ cy.mount(
+
+ );
+
+ // value=25, min=0, max=100 → position should be 25%
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-handle]")
+ .should("have.attr", "style", "inset-inline-start: clamp(0%, 25%, 100%);");
+ });
+
+ it("aria-valuetext reflects the custom label", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-handle]")
+ .should("have.attr", "aria-valuetext", "Room Temp");
+ });
+
+ it("Tooltip shows custom label", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]").as("slider");
+ cy.get("@slider").shadow().find("[ui5-slider-handle]").realClick();
+
+ cy.get("@slider")
+ .shadow()
+ .find("[ui5-slider-tooltip]")
+ .should("have.attr", "value", "Room Temp");
+ });
+
+ it("min and max are auto-derived from tickmarks", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-handle]")
+ .should("have.attr", "aria-valuemin", "0")
+ .should("have.attr", "aria-valuemax", "100");
+ });
+
+ it("Tickmarks auto-show without showTickmarks attribute", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark")
+ .should("have.length.at.least", 4);
+ });
+
+ it("Backward compatibility - slider without tickmarks works as before", () => {
+ cy.mount();
+
+ cy.get("[ui5-slider]").as("slider");
+ cy.get("@slider").realClick();
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 6);
+ });
+});
diff --git a/packages/main/src/Slider.ts b/packages/main/src/Slider.ts
index e0853d029706..6a699472dce3 100644
--- a/packages/main/src/Slider.ts
+++ b/packages/main/src/Slider.ts
@@ -2,11 +2,12 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
-import { isEscape, isF2 } from "@ui5/webcomponents-base/dist/Keys.js";
+import { isEscape, isHome, isEnd, isF2 } from "@ui5/webcomponents-base/dist/Keys.js";
import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
import type ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import SliderBase from "./SliderBase.js";
import type SliderTooltip from "./SliderTooltip.js";
+import type { Tickmark } from "./SliderScale.js";
// Template
import SliderTemplate from "./SliderTemplate.js";
@@ -95,13 +96,27 @@ class Slider extends SliderBase implements IFormInputElement {
/**
* Defines the size of the slider's selection intervals (e.g. min = 0, max = 10, step = 5 would result in possible selection of the values 0, 5, 10).
*
- * **Note:** If set to 0 the slider handle movement is disabled.
+ * **Note:** If set to 0 the slider handle movement is disabled. When `tickmarks` is set, `step` is ignored.
* @default 1
* @public
*/
@property({ type: Number })
step = 1;
+ /**
+ * Defines custom tickmarks for the slider scale.
+ * When set, the slider enters "custom values" mode: the handle snaps only to defined values,
+ * custom labels are displayed, and `min`/`max`/`step` are derived from the tickmarks array.
+ *
+ * Each tickmark object has a numeric `value` and an optional `label` string.
+ *
+ * **Note:** When `tickmarks` is provided, `step`, `min`, `max`, and `showTickmarks` are ignored.
+ * @default []
+ * @public
+ */
+ @property({ type: Array })
+ tickmarks: Array = [];
+
@property()
tooltipValueState: `${ValueState}` = "None";
@@ -118,6 +133,64 @@ class Slider extends SliderBase implements IFormInputElement {
return this.value.toString();
}
+ get _isCustomValuesMode(): boolean {
+ return this.tickmarks.length > 0;
+ }
+
+ get _effectiveMin(): number {
+ if (this._isCustomValuesMode) {
+ return Math.min(...this.tickmarks.map(t => t.value));
+ }
+ return this.min;
+ }
+
+ get _effectiveMax(): number {
+ if (this._isCustomValuesMode) {
+ return Math.max(...this.tickmarks.map(t => t.value));
+ }
+ return this.max;
+ }
+
+ get _ariaValueText(): string | undefined {
+ if (!this._isCustomValuesMode) {
+ return undefined;
+ }
+ return this._getCustomLabel(this.value) || undefined;
+ }
+
+ _snapToNearestTickmark(rawValue: number): number {
+ const values = this.tickmarks.map(t => t.value);
+ return values.reduce((prev, curr) =>
+ (Math.abs(curr - rawValue) < Math.abs(prev - rawValue) ? curr : prev)
+ );
+ }
+
+ _getCustomLabel(value: number): string | undefined {
+ return this.tickmarks.find(t => t.value === value)?.label;
+ }
+
+ _getSortedTickmarkValues(): Array {
+ return this.tickmarks.map(t => t.value).sort((a, b) => a - b);
+ }
+
+ _findCurrentIndex(sortedValues: Array): number {
+ const exactIndex = sortedValues.indexOf(this.value);
+ if (exactIndex !== -1) {
+ return exactIndex;
+ }
+ // Find closest index
+ let closest = 0;
+ let minDist = Math.abs(sortedValues[0] - this.value);
+ for (let i = 1; i < sortedValues.length; i++) {
+ const dist = Math.abs(sortedValues[i] - this.value);
+ if (dist < minDist) {
+ minDist = dist;
+ closest = i;
+ }
+ }
+ return closest;
+ }
+
@i18n("@ui5/webcomponents")
static i18nBundle: I18nBundle;
@@ -131,10 +204,14 @@ class Slider extends SliderBase implements IFormInputElement {
* @private
*/
onBeforeRendering() {
- // Clamp value visually without modifying the actual value property
- const ctor = this.constructor as typeof Slider;
- const clampedValue = ctor.clipValue(this.value, this.min, this.max);
- this._updateHandleAndProgress(clampedValue);
+ if (this._isCustomValuesMode) {
+ const snappedValue = this._snapToNearestTickmark(this.value);
+ this._updateHandleAndProgress(snappedValue);
+ } else {
+ const ctor = this.constructor as typeof Slider;
+ const clampedValue = ctor.clipValue(this.value, this.min, this.max);
+ this._updateHandleAndProgress(clampedValue);
+ }
}
onAfterRendering(): void {
@@ -152,22 +229,22 @@ class Slider extends SliderBase implements IFormInputElement {
* @private
*/
_onmousedown(e: TouchEvent | MouseEvent) {
- // If step is 0 no interaction is available because there is no constant
- // (equal for all user environments) quantitative representation of the value
if (this.disabled || this.step === 0 || (e.target as HTMLElement).hasAttribute("ui5-slider-tooltip")) {
return;
}
+ if (this._isCustomValuesMode) {
+ this._onmousedownCustom(e);
+ return;
+ }
+
const newValue = this.handleDownBase(e);
this._valueOnInteractionStart = this.value;
- // Set initial value if one is not set previously on focus in.
- // It will be restored if ESC key is pressed.
if (this._valueInitial === undefined) {
this._valueInitial = this.value;
}
- // Do not yet update the Slider if press is over a handle. It will be updated if the user drags the mouse.
const ctor = this.constructor as typeof Slider;
if (!this._isHandlePressed(ctor.getPageXValueFromEvent(e))) {
const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step);
@@ -178,6 +255,41 @@ class Slider extends SliderBase implements IFormInputElement {
}
}
+ _onmousedownCustom(e: TouchEvent | MouseEvent) {
+ const ctor = this.constructor as typeof Slider;
+ const min = this._effectiveMin;
+ const max = this._effectiveMax;
+ const domRect = this.getBoundingClientRect();
+ const pageX = ctor.getPageXValueFromEvent(e);
+
+ this._isUserInteraction = true;
+ this._valueOnInteractionStart = this.value;
+
+ if (this._valueInitial === undefined) {
+ this._valueInitial = this.value;
+ }
+
+ window.addEventListener("mouseup", this._upHandler);
+ window.addEventListener("touchend", this._upHandler);
+ window.addEventListener("mouseout", this._windowMouseoutHandler);
+ if (e instanceof TouchEvent) {
+ window.addEventListener("touchmove", this._moveHandler);
+ } else {
+ window.addEventListener("mousemove", this._moveHandler);
+ }
+
+ this._handleFocusOnMouseDown(e);
+
+ if (!this._isHandlePressed(pageX)) {
+ const rawValue = ctor.computedValueFromPageX(pageX, min, max, domRect, this.directionStart);
+ const newValue = this._snapToNearestTickmark(rawValue);
+ this._updateHandleAndProgress(newValue);
+ this.value = newValue;
+ this.tooltipValue = this._getCustomLabel(newValue) || newValue.toString();
+ this.updateStateStorageAndFireInputEvent("value");
+ }
+ }
+
_onfocusin() {
// Set initial value if one is not set previously on focus in.
// It will be restored if ESC key is pressed.
@@ -240,6 +352,10 @@ class Slider extends SliderBase implements IFormInputElement {
}
_onTooltipOpen() {
+ if (this._isCustomValuesMode) {
+ this.tooltipValue = this._getCustomLabel(this.value) || this.value.toString();
+ return;
+ }
const ctor = this.constructor as typeof Slider;
const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step);
this.tooltipValue = this.value.toFixed(stepPrecision);
@@ -256,6 +372,21 @@ class Slider extends SliderBase implements IFormInputElement {
_handleMove(e: TouchEvent | MouseEvent) {
e.preventDefault();
+ if (this._isCustomValuesMode) {
+ const ctor = this.constructor as typeof Slider;
+ const min = this._effectiveMin;
+ const max = this._effectiveMax;
+ const pageX = ctor.getPageXValueFromEvent(e);
+ const rawValue = ctor.computedValueFromPageX(pageX, min, max, this.getBoundingClientRect(), this.directionStart);
+ const newValue = this._snapToNearestTickmark(rawValue);
+
+ this._updateHandleAndProgress(newValue);
+ this.value = newValue;
+ this.tooltipValue = this._getCustomLabel(newValue) || newValue.toString();
+ this.updateStateStorageAndFireInputEvent("value");
+ return;
+ }
+
const ctor = this.constructor as typeof Slider;
const newValue = ctor.getValueFromInteraction(e, this.step, this.min, this.max, this.getBoundingClientRect(), this.directionStart);
const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step);
@@ -302,16 +433,19 @@ class Slider extends SliderBase implements IFormInputElement {
* @private
*/
_updateHandleAndProgress(newValue: number) {
- const max = this.max;
- const min = this.min;
+ const max = this._effectiveMax;
+ const min = this._effectiveMin;
- // The progress (completed) percentage of the slider.
this._progressPercentage = (newValue - min) / (max - min);
- // How many pixels from the left end of the slider will be the placed the affected by the user action handle
this._handlePositionFromStart = this._progressPercentage * 100;
}
_handleActionKeyPress(e: KeyboardEvent) {
+ if (this._isCustomValuesMode) {
+ this._handleActionKeyPressCustom(e);
+ return;
+ }
+
const min = this.min;
const max = this.max;
const currentValue = this.value;
@@ -327,6 +461,37 @@ class Slider extends SliderBase implements IFormInputElement {
}
}
+ _handleActionKeyPressCustom(e: KeyboardEvent) {
+ const sortedValues = this._getSortedTickmarkValues();
+ const currentIndex = this._findCurrentIndex(sortedValues);
+ let newValue: number;
+
+ if (isEscape(e)) {
+ newValue = this._valueInitial!;
+ } else if (isHome(e)) {
+ newValue = sortedValues[0];
+ } else if (isEnd(e)) {
+ newValue = sortedValues[sortedValues.length - 1];
+ } else {
+ const isUp = SliderBase._isIncreaseValueAction(e, this.directionStart);
+ const isBigStep = SliderBase._isBigStepAction(e);
+ const jumpSize = isBigStep ? Math.max(1, Math.round(sortedValues.length / 10)) : 1;
+
+ if (isUp) {
+ newValue = sortedValues[Math.min(currentIndex + jumpSize, sortedValues.length - 1)];
+ } else {
+ newValue = sortedValues[Math.max(currentIndex - jumpSize, 0)];
+ }
+ }
+
+ if (newValue !== this.value) {
+ this._updateHandleAndProgress(newValue);
+ this.value = newValue;
+ this.tooltipValue = this._getCustomLabel(newValue) || newValue.toString();
+ this.updateStateStorageAndFireInputEvent("value");
+ }
+ }
+
_onTooltopForwardFocus(e: CustomEvent) {
const tooltip = e.target as SliderTooltip;
diff --git a/packages/main/src/SliderScale.ts b/packages/main/src/SliderScale.ts
index 08abc2a11032..f465ce89c53f 100644
--- a/packages/main/src/SliderScale.ts
+++ b/packages/main/src/SliderScale.ts
@@ -96,7 +96,7 @@ class SliderScale extends UI5Element {
/**
* Defines custom tickmarks to be displayed on the scale.
* @default []
- * @private
+ * @public
*/
@property({ type: Array })
tickmarks: Array = [];
diff --git a/packages/main/src/SliderTemplate.tsx b/packages/main/src/SliderTemplate.tsx
index e983285cf34b..330a046eaba0 100644
--- a/packages/main/src/SliderTemplate.tsx
+++ b/packages/main/src/SliderTemplate.tsx
@@ -10,23 +10,24 @@ const _handlePosition = (min: number, max: number, value: number) => {
};
const handle = (slider: Slider) => {
- const position = _handlePosition(slider.min, slider.max, slider.value);
+ const position = _handlePosition(slider._effectiveMin, slider._effectiveMax, slider.value);
return (
<>
(
Basic RTL Slider
+
+ Custom Values - Temperature
+
+
+ Custom Values - Skill Level
+
+
+ Custom Values - Timeline
+
+
+
Event Testing Slider
@@ -124,18 +135,41 @@ Event Testing Result Slider
densityButtons.forEach(button => {
button.addEventListener('click', () => {
const density = button.getAttribute('data-density');
-
+
if (density === 'compact') {
document.body.classList.add('ui5-content-density-compact');
} else {
document.body.classList.remove('ui5-content-density-compact');
}
-
+
// Update active state
densityButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
});
});
+
+ // Custom Values demos
+ document.getElementById("custom-values-temp").tickmarks = [
+ { value: 0, label: "Freezing" },
+ { value: 25, label: "Room Temp" },
+ { value: 50, label: "Warm" },
+ { value: 75, label: "Hot" },
+ { value: 100, label: "Boiling" }
+ ];
+
+ document.getElementById("custom-values-skill").tickmarks = [
+ { value: 0, label: "Beginner" },
+ { value: 1, label: "Elementary" },
+ { value: 2, label: "Intermediate" },
+ { value: 3, label: "Advanced" },
+ { value: 4, label: "Expert" }
+ ];
+
+ document.getElementById("custom-values-timeline").tickmarks = [
+ { value: 0, label: "Past" },
+ { value: 1, label: "Present" },
+ { value: 2, label: "Future" }
+ ];