diff --git a/packages/main/cypress/specs/RangeSlider.cy.tsx b/packages/main/cypress/specs/RangeSlider.cy.tsx
index bbb533452e56..af0341671500 100755
--- a/packages/main/cypress/specs/RangeSlider.cy.tsx
+++ b/packages/main/cypress/specs/RangeSlider.cy.tsx
@@ -1863,3 +1863,147 @@ describe("Accessibility", () => {
});
});
});
+
+describe("Custom Tickmarks", () => {
+ const customTickmarks = [
+ { value: 0, label: "Freezing" },
+ { value: 25, label: "Cool" },
+ { value: 50, label: "Warm" },
+ { value: 75, label: "Hot" },
+ { 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-range-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .should("have.length", 5);
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .eq(0)
+ .should("have.text", "Freezing");
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark-label")
+ .eq(4)
+ .should("have.text", "Boiling");
+ });
+
+ it("Allows free movement based on step, not snapping to tickmarks", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]").as("slider");
+
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='Start']").realClick();
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='Start']").realPress("ArrowRight");
+
+ cy.get("@slider").should("have.attr", "start-value", "1");
+ });
+
+ it("Arrow key moves by step value for end handle", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]").as("slider");
+
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='End']").realClick();
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='End']").realPress("ArrowRight");
+
+ cy.get("@slider").should("have.attr", "end-value", "55");
+ });
+
+ it("aria-valuetext reflects the custom label when value matches a tickmark", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-handle][handle-type='Start']")
+ .should("have.attr", "aria-valuetext", "Cool");
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-handle][handle-type='End']")
+ .should("have.attr", "aria-valuetext", "Hot");
+ });
+
+ it("aria-valuetext is absent when value does not match a tickmark", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-handle][handle-type='Start']")
+ .should("not.have.attr", "aria-valuetext");
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-handle][handle-type='End']")
+ .should("not.have.attr", "aria-valuetext");
+ });
+
+ it("Tooltip shows custom label when value matches a tickmark", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]").as("slider");
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='Start']").realClick();
+
+ cy.get("@slider")
+ .shadow()
+ .find("[ui5-slider-tooltip][data-sap-ui-start-value]")
+ .should("have.attr", "value", "Cool");
+
+ cy.get("@slider")
+ .shadow()
+ .find("[ui5-slider-tooltip][data-sap-ui-end-value]")
+ .should("have.attr", "value", "Hot");
+ });
+
+ it("Tickmarks auto-show without showTickmarks attribute", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-range-slider]")
+ .shadow()
+ .find("[ui5-slider-scale]")
+ .shadow()
+ .find(".ui5-slider-scale-tickmark")
+ .should("have.length.at.least", 5);
+ });
+
+ it("Backward compatibility - range slider without tickmarks works as before", () => {
+ cy.mount();
+
+ cy.get("[ui5-range-slider]").as("slider");
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='End']").realClick();
+
+ cy.get("@slider").shadow().find("[ui5-slider-handle][handle-type='End']").realPress("ArrowRight");
+ cy.get("@slider").should("have.attr", "end-value", "8");
+ });
+});
diff --git a/packages/main/cypress/specs/Slider.cy.tsx b/packages/main/cypress/specs/Slider.cy.tsx
index 6cc1da2c631c..3b720c851422 100644
--- a/packages/main/cypress/specs/Slider.cy.tsx
+++ b/packages/main/cypress/specs/Slider.cy.tsx
@@ -1036,3 +1036,163 @@ 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("Allows free movement based on step, not snapping to tickmarks", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]").as("slider");
+ cy.get("@slider").realClick({ position: "left" });
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 1);
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 2);
+ });
+
+ it("Arrow key moves by step 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", 5);
+
+ cy.get("@slider").realPress("ArrowRight");
+ cy.get("@slider").should("have.value", 10);
+ });
+
+ it("Home/End keys jump to min/max", () => {
+ 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 when value matches a tickmark", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-handle]")
+ .should("have.attr", "aria-valuetext", "Room Temp");
+ });
+
+ it("aria-valuetext is absent when value does not match a tickmark", () => {
+ cy.mount(
+
+ );
+
+ cy.get("[ui5-slider]")
+ .shadow()
+ .find("[ui5-slider-handle]")
+ .should("not.have.attr", "aria-valuetext");
+ });
+
+ it("Tooltip shows custom label when value matches a tickmark", () => {
+ 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("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/RangeSlider.ts b/packages/main/src/RangeSlider.ts
index d450ade72b1b..215be90694c5 100644
--- a/packages/main/src/RangeSlider.ts
+++ b/packages/main/src/RangeSlider.ts
@@ -15,6 +15,7 @@ import { getAssociatedLabelForTexts } from "@ui5/webcomponents-base/dist/util/Ac
import SliderBase from "./SliderBase.js";
import RangeSliderTemplate from "./RangeSliderTemplate.js";
import type SliderTooltip from "./SliderTooltip.js";
+import type { Tickmark } from "./SliderScale.js";
// Texts
import {
@@ -107,7 +108,7 @@ class RangeSlider extends SliderBase implements IFormInputElement {
@property({ type: Number })
set startValue(value: number) {
this._startValue = value;
- this.tooltipStartValue = value?.toString() ?? "";
+ this.tooltipStartValue = this._getCustomLabel(value) || (value?.toString() ?? "");
}
get startValue(): number {
@@ -124,7 +125,7 @@ class RangeSlider extends SliderBase implements IFormInputElement {
@property({ type: Number })
set endValue(value: number) {
this._endValue = value;
- this.tooltipEndValue = value?.toString() ?? "";
+ this.tooltipEndValue = this._getCustomLabel(value) || (value?.toString() ?? "");
}
get endValue(): number {
@@ -143,6 +144,24 @@ class RangeSlider extends SliderBase implements IFormInputElement {
@property()
tooltipEndValueState: `${ValueState}` = "None";
+ /**
+ * Defines custom tickmarks with labels on the slider scale.
+ * Each tickmark object has a numeric `value` and an optional `label` string.
+ * Tickmarks are purely visual — they display labeled markers at specific positions
+ * but do not affect the slider's movement behavior. The slider still moves
+ * according to `min`, `max`, and `step`.
+ *
+ * When the current value matches a tickmark value, the tickmark's label
+ * is shown in the tooltip and announced via `aria-valuetext`.
+ *
+ * **Note:** When `tickmarks` is provided, the scale is automatically shown
+ * (equivalent to `showTickmarks`).
+ * @default []
+ * @public
+ */
+ @property({ type: Array })
+ tickmarks: Array = [];
+
@property({ type: Boolean })
rangePressed = false;
@@ -226,6 +245,22 @@ class RangeSlider extends SliderBase implements IFormInputElement {
return this.disabled || undefined;
}
+ get _hasCustomTickmarks(): boolean {
+ return this.tickmarks.length > 0;
+ }
+
+ _getCustomLabel(value: number): string | undefined {
+ return this.tickmarks.find(t => t.value === value)?.label;
+ }
+
+ get _ariaValueTextStart(): string | undefined {
+ return this._getCustomLabel(this.startValue) || undefined;
+ }
+
+ get _ariaValueTextEnd(): string | undefined {
+ return this._getCustomLabel(this.endValue) || undefined;
+ }
+
get _ariaLabelledByText() {
return RangeSlider.i18nBundle.getText(RANGE_SLIDER_ARIA_DESCRIPTION);
}
@@ -429,8 +464,8 @@ class RangeSlider extends SliderBase implements IFormInputElement {
this.update(affectedValue, newStartValue, newEndValue);
}
- this.tooltipStartValue = this.startValue.toString();
- this.tooltipEndValue = this.endValue.toString();
+ this.tooltipStartValue = this._getCustomLabel(this.startValue) || this.startValue.toString();
+ this.tooltipEndValue = this._getCustomLabel(this.endValue) || this.endValue.toString();
}
/**
@@ -597,8 +632,8 @@ class RangeSlider extends SliderBase implements IFormInputElement {
// Updates UI and state when dragging of the whole selected range
this._updateValueOnRangeDrag(e);
- this.tooltipStartValue = this.startValue.toString();
- this.tooltipEndValue = this.endValue.toString();
+ this.tooltipStartValue = this._getCustomLabel(this.startValue) || this.startValue.toString();
+ this.tooltipEndValue = this._getCustomLabel(this.endValue) || this.endValue.toString();
}
/**
@@ -966,8 +1001,8 @@ class RangeSlider extends SliderBase implements IFormInputElement {
return;
}
- this.tooltipStartValue = this.startValue.toString();
- this.tooltipEndValue = this.endValue.toString();
+ this.tooltipStartValue = this._getCustomLabel(this.startValue) || this.startValue.toString();
+ this.tooltipEndValue = this._getCustomLabel(this.endValue) || this.endValue.toString();
}
_onTooltipInput(e: CustomEvent) {
diff --git a/packages/main/src/RangeSliderTemplate.tsx b/packages/main/src/RangeSliderTemplate.tsx
index 3d764751ff57..e996489e3422 100644
--- a/packages/main/src/RangeSliderTemplate.tsx
+++ b/packages/main/src/RangeSliderTemplate.tsx
@@ -28,6 +28,7 @@ const startHandle = (slider: RangeSlider) => {
aria-valuemin={slider.min}
aria-valuemax={slider.max}
aria-valuenow={slider.startValue}
+ aria-valuetext={slider._ariaValueTextStart}
aria-label={slider._ariaLabelStartHandle}
aria-disabled={slider._ariaDisabled}
aria-describedby={slider._ariaDescribedByHandleText}
@@ -62,6 +63,7 @@ const endHandle = (slider: RangeSlider) => {
aria-valuemin={slider.min}
aria-valuemax={slider.max}
aria-valuenow={slider.endValue}
+ aria-valuetext={slider._ariaValueTextEnd}
aria-label={slider._ariaLabelEndHandle}
aria-disabled={slider._ariaDisabled}
aria-describedby={slider._ariaDescribedByHandleText}
@@ -134,8 +136,9 @@ export default function RangeSliderTemplate(this: RangeSlider) {
min={this.min}
max={this.max}
step={this._effectiveStep}
- showTickmarks={this.showTickmarks}
- labelInterval={this.labelInterval}
+ showTickmarks={this.showTickmarks || this._hasCustomTickmarks}
+ labelInterval={this._hasCustomTickmarks ? 1 : this.labelInterval}
+ tickmarks={this.tickmarks}
progressTabIndex={this._tabIndex}
progressAriaValueNow={this._ariaValueNow}
progressAriaValueText={`From ${this.startValue} to ${this.endValue}`}
diff --git a/packages/main/src/Slider.ts b/packages/main/src/Slider.ts
index 2b39cd2747cd..a9765a2ae970 100644
--- a/packages/main/src/Slider.ts
+++ b/packages/main/src/Slider.ts
@@ -7,6 +7,7 @@ import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/In
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";
@@ -102,6 +103,24 @@ class Slider extends SliderBase implements IFormInputElement {
@property({ type: Number })
step = 1;
+ /**
+ * Defines custom tickmarks with labels on the slider scale.
+ * Each tickmark object has a numeric `value` and an optional `label` string.
+ * Tickmarks are purely visual — they display labeled markers at specific positions
+ * but do not affect the slider's movement behavior. The slider still moves
+ * according to `min`, `max`, and `step`.
+ *
+ * When the current value matches a tickmark value, the tickmark's label
+ * is shown in the tooltip and announced via `aria-valuetext`.
+ *
+ * **Note:** When `tickmarks` is provided, the scale is automatically shown
+ * (equivalent to `showTickmarks`).
+ * @default []
+ * @public
+ */
+ @property({ type: Array })
+ tickmarks: Array = [];
+
@property()
tooltipValueState: `${ValueState}` = "None";
@@ -118,6 +137,19 @@ class Slider extends SliderBase implements IFormInputElement {
return this.value.toString();
}
+ get _hasCustomTickmarks(): boolean {
+ return this.tickmarks.length > 0;
+ }
+
+ get _ariaValueText(): string | undefined {
+ const label = this._getCustomLabel(this.value);
+ return label || undefined;
+ }
+
+ _getCustomLabel(value: number): string | undefined {
+ return this.tickmarks.find(t => t.value === value)?.label;
+ }
+
@i18n("@ui5/webcomponents")
static i18nBundle: I18nBundle;
@@ -131,7 +163,6 @@ 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);
@@ -148,8 +179,6 @@ 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;
}
@@ -157,19 +186,16 @@ class Slider extends SliderBase implements IFormInputElement {
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);
this._updateHandleAndProgress(newValue);
this.value = newValue;
- this.tooltipValue = newValue.toFixed(stepPrecision);
+ this.tooltipValue = this._getCustomLabel(newValue) || newValue.toFixed(stepPrecision);
this.updateStateStorageAndFireInputEvent("value");
}
}
@@ -236,6 +262,11 @@ class Slider extends SliderBase implements IFormInputElement {
}
_onTooltipOpen() {
+ const customLabel = this._getCustomLabel(this.value);
+ if (customLabel) {
+ this.tooltipValue = customLabel;
+ return;
+ }
const ctor = this.constructor as typeof Slider;
const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step);
this.tooltipValue = this.value.toFixed(stepPrecision);
@@ -258,7 +289,7 @@ class Slider extends SliderBase implements IFormInputElement {
this._updateHandleAndProgress(newValue);
this.value = newValue;
- this.tooltipValue = newValue.toFixed(stepPrecision);
+ this.tooltipValue = this._getCustomLabel(newValue) || newValue.toFixed(stepPrecision);
this.updateStateStorageAndFireInputEvent("value");
}
@@ -301,9 +332,7 @@ class Slider extends SliderBase implements IFormInputElement {
const max = this.max;
const min = this.min;
- // 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;
}
@@ -318,7 +347,7 @@ class Slider extends SliderBase implements IFormInputElement {
const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step);
this._updateHandleAndProgress(newValue!);
this.value = newValue!;
- this.tooltipValue = this.value.toFixed(stepPrecision);
+ this.tooltipValue = this._getCustomLabel(newValue!) || this.value.toFixed(stepPrecision);
this.updateStateStorageAndFireInputEvent("value");
}
}
diff --git a/packages/main/src/SliderScale.ts b/packages/main/src/SliderScale.ts
index 72547b876edd..53166258de99 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 2359c5559ba9..9df1c6804c90 100644
--- a/packages/main/src/SliderTemplate.tsx
+++ b/packages/main/src/SliderTemplate.tsx
@@ -27,6 +27,7 @@ const handle = (slider: Slider) => {
aria-valuemin={slider.min}
aria-valuemax={slider.max}
aria-valuenow={slider.value}
+ aria-valuetext={slider._ariaValueText}
aria-label={slider._ariaLabel}
aria-disabled={slider._ariaDisabled}
aria-describedby={slider._ariaDescribedByHandleText}
@@ -77,8 +78,9 @@ export default function SliderTemplate(this: Slider) {
max={this.max}
step={this.step}
startValue={this.min}
- showTickmarks={this.showTickmarks}
- labelInterval={this.labelInterval}
+ showTickmarks={this.showTickmarks || this._hasCustomTickmarks}
+ labelInterval={this._hasCustomTickmarks ? 1 : this.labelInterval}
+ tickmarks={this.tickmarks}
onFocusOut={this._onfocusout}
onFocusIn={this._onfocusin}
part="scale"
diff --git a/packages/main/test/pages/RangeSlider.html b/packages/main/test/pages/RangeSlider.html
index 58ca1466d64c..95c8bd37b3f0 100644
--- a/packages/main/test/pages/RangeSlider.html
+++ b/packages/main/test/pages/RangeSlider.html
@@ -48,6 +48,14 @@ Range Slider with steps, input tooltips, tickmarks
+
+ Custom Tickmarks - Temperature Range
+
+
+ Custom Tickmarks - Price Range
+
+
+
Event Testing Slider
@@ -72,6 +80,23 @@ Event Testing Result Slider