From 0512b93a60cece7a2dc435867df8d8bfb02d6b3e Mon Sep 17 00:00:00 2001 From: "Martin R. Hristov" Date: Wed, 29 Apr 2026 16:20:12 +0300 Subject: [PATCH 1/4] feat(ui5-slider): support custom values via tickmarks property Allow developers to define custom non-numeric labels on slider tick marks by setting the `tickmarks` property with an array of {value, label} objects. When set, the slider enters "custom values" mode: - Handle snaps only to defined tickmark values - Custom labels are displayed on the scale - Tooltip shows the custom label instead of numeric value - aria-valuetext announces the custom label for accessibility - min/max are auto-derived from tickmark values - Tickmarks auto-show without requiring show-tickmarks attribute - step property is ignored (navigation moves between defined values) - Editable tooltip is disabled (only defined values are valid) Resolves #9730, resolves #9058 --- packages/main/cypress/specs/Slider.cy.tsx | 169 +++++++++++++++++++ packages/main/src/Slider.ts | 195 ++++++++++++++++++++-- packages/main/src/SliderScale.ts | 2 +- packages/main/src/SliderTemplate.tsx | 28 ++-- packages/main/test/pages/Slider.html | 38 ++++- 5 files changed, 401 insertions(+), 31 deletions(-) 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..03cd1cd4edda 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" } + ]; From 17b6f09eabda10b4f170de1167389bc7d3e594d4 Mon Sep 17 00:00:00 2001 From: "Martin R. Hristov" Date: Wed, 29 Apr 2026 16:21:15 +0300 Subject: [PATCH 2/4] fix(ui5-slider): resolve no-confusing-arrow lint warning --- packages/main/src/Slider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/src/Slider.ts b/packages/main/src/Slider.ts index 03cd1cd4edda..6a699472dce3 100644 --- a/packages/main/src/Slider.ts +++ b/packages/main/src/Slider.ts @@ -161,7 +161,7 @@ class Slider extends SliderBase implements IFormInputElement { _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 + (Math.abs(curr - rawValue) < Math.abs(prev - rawValue) ? curr : prev) ); } From f926953df74aa74f4ed5258e472d42c7086a9c55 Mon Sep 17 00:00:00 2001 From: "Martin R. Hristov" Date: Tue, 5 May 2026 14:45:06 +0300 Subject: [PATCH 3/4] feat(ui5-slider, ui5-range-slider): make tickmarks visual-only Custom tickmarks now serve as visual labels at specific positions on the scale without affecting slider movement. The slider moves freely based on min/max/step regardless of tickmark positions. When the current value matches a tickmark value, its label is shown in the tooltip and announced via aria-valuetext. BREAKING CHANGE: The tickmarks property no longer causes the slider to snap exclusively to defined values. Users must set min/max/step explicitly alongside tickmarks. --- .../main/cypress/specs/RangeSlider.cy.tsx | 144 ++++++++++++++ packages/main/cypress/specs/Slider.cy.tsx | 71 +++---- packages/main/src/RangeSlider.ts | 51 ++++- packages/main/src/RangeSliderTemplate.tsx | 7 +- packages/main/src/Slider.ts | 186 +++--------------- packages/main/src/SliderTemplate.tsx | 26 +-- packages/main/test/pages/RangeSlider.html | 25 +++ packages/main/test/pages/Slider.html | 15 +- 8 files changed, 293 insertions(+), 232 deletions(-) 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 5c6ffa78e9c2..3b720c851422 100644 --- a/packages/main/cypress/specs/Slider.cy.tsx +++ b/packages/main/cypress/specs/Slider.cy.tsx @@ -1052,7 +1052,7 @@ describe("Custom Values", () => { it("Renders custom labels on tickmarks", () => { cy.mount( - + ); cy.get("[ui5-slider]") @@ -1079,22 +1079,24 @@ describe("Custom Values", () => { .should("have.text", "Boiling"); }); - it("Snaps value to nearest tickmark on click", () => { + 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" }); - // Click roughly in the middle should snap to 50 (Warm) - cy.get("@slider").realClick({ position: "center" }); + cy.get("@slider").realPress("ArrowRight"); + cy.get("@slider").should("have.value", 1); - cy.get("@slider").should("have.value", 50); + cy.get("@slider").realPress("ArrowRight"); + cy.get("@slider").should("have.value", 2); }); - it("Arrow key navigates to next/previous custom value", () => { + it("Arrow key moves by step value", () => { cy.mount( - + ); cy.get("[ui5-slider]").as("slider"); @@ -1102,25 +1104,15 @@ describe("Custom Values", () => { 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); + cy.get("@slider").should("have.value", 5); - // 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); + cy.get("@slider").should("have.value", 10); }); - it("Home/End keys jump to first/last custom value", () => { + it("Home/End keys jump to min/max", () => { cy.mount( - + ); cy.get("[ui5-slider]").as("slider"); @@ -1135,7 +1127,7 @@ describe("Custom Values", () => { it("Handle position is correct for non-uniform tickmark spacing", () => { cy.mount( - + ); // value=25, min=0, max=100 → position should be 25% @@ -1145,9 +1137,9 @@ describe("Custom Values", () => { .should("have.attr", "style", "inset-inline-start: clamp(0%, 25%, 100%);"); }); - it("aria-valuetext reflects the custom label", () => { + it("aria-valuetext reflects the custom label when value matches a tickmark", () => { cy.mount( - + ); cy.get("[ui5-slider]") @@ -1156,35 +1148,34 @@ describe("Custom Values", () => { .should("have.attr", "aria-valuetext", "Room Temp"); }); - it("Tooltip shows custom label", () => { + it("aria-valuetext is absent when value does not match a tickmark", () => { cy.mount( - + ); - cy.get("[ui5-slider]").as("slider"); - cy.get("@slider").shadow().find("[ui5-slider-handle]").realClick(); - - cy.get("@slider") + cy.get("[ui5-slider]") .shadow() - .find("[ui5-slider-tooltip]") - .should("have.attr", "value", "Room Temp"); + .find("[ui5-slider-handle]") + .should("not.have.attr", "aria-valuetext"); }); - it("min and max are auto-derived from tickmarks", () => { + it("Tooltip shows custom label when value matches a tickmark", () => { cy.mount( - + ); - cy.get("[ui5-slider]") + cy.get("[ui5-slider]").as("slider"); + cy.get("@slider").shadow().find("[ui5-slider-handle]").realClick(); + + cy.get("@slider") .shadow() - .find("[ui5-slider-handle]") - .should("have.attr", "aria-valuemin", "0") - .should("have.attr", "aria-valuemax", "100"); + .find("[ui5-slider-tooltip]") + .should("have.attr", "value", "Room Temp"); }); it("Tickmarks auto-show without showTickmarks attribute", () => { cy.mount( - + ); cy.get("[ui5-slider]") 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 5f85de03d297..a9765a2ae970 100644 --- a/packages/main/src/Slider.ts +++ b/packages/main/src/Slider.ts @@ -2,7 +2,7 @@ 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, isHome, isEnd, isF2 } from "@ui5/webcomponents-base/dist/Keys.js"; +import { isEscape, 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"; @@ -96,7 +96,7 @@ 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. When `tickmarks` is set, `step` is ignored. + * **Note:** If set to 0 the slider handle movement is disabled. * @default 1 * @public */ @@ -104,13 +104,17 @@ class Slider extends SliderBase implements IFormInputElement { 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. - * + * 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, `step`, `min`, `max`, and `showTickmarks` are ignored. + * **Note:** When `tickmarks` is provided, the scale is automatically shown + * (equivalent to `showTickmarks`). * @default [] * @public */ @@ -133,64 +137,19 @@ class Slider extends SliderBase implements IFormInputElement { return this.value.toString(); } - get _isCustomValuesMode(): boolean { + get _hasCustomTickmarks(): 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) - ); + const label = this._getCustomLabel(this.value); + return label || undefined; } _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; @@ -204,14 +163,9 @@ class Slider extends SliderBase implements IFormInputElement { * @private */ onBeforeRendering() { - 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); - } + const ctor = this.constructor as typeof Slider; + const clampedValue = ctor.clipValue(this.value, this.min, this.max); + this._updateHandleAndProgress(clampedValue); } onAfterRendering(): void { @@ -229,11 +183,6 @@ class Slider extends SliderBase implements IFormInputElement { return; } - if (this._isCustomValuesMode) { - this._onmousedownCustom(e); - return; - } - const newValue = this.handleDownBase(e); this._valueOnInteractionStart = this.value; @@ -246,42 +195,7 @@ class Slider extends SliderBase implements IFormInputElement { const stepPrecision = ctor._getDecimalPrecisionOfNumber(this.step); this._updateHandleAndProgress(newValue); this.value = newValue; - this.tooltipValue = newValue.toFixed(stepPrecision); - this.updateStateStorageAndFireInputEvent("value"); - } - } - - _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.tooltipValue = this._getCustomLabel(newValue) || newValue.toFixed(stepPrecision); this.updateStateStorageAndFireInputEvent("value"); } } @@ -348,8 +262,9 @@ class Slider extends SliderBase implements IFormInputElement { } _onTooltipOpen() { - if (this._isCustomValuesMode) { - this.tooltipValue = this._getCustomLabel(this.value) || this.value.toString(); + const customLabel = this._getCustomLabel(this.value); + if (customLabel) { + this.tooltipValue = customLabel; return; } const ctor = this.constructor as typeof Slider; @@ -368,28 +283,13 @@ 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); this._updateHandleAndProgress(newValue); this.value = newValue; - this.tooltipValue = newValue.toFixed(stepPrecision); + this.tooltipValue = this._getCustomLabel(newValue) || newValue.toFixed(stepPrecision); this.updateStateStorageAndFireInputEvent("value"); } @@ -429,19 +329,14 @@ class Slider extends SliderBase implements IFormInputElement { * @private */ _updateHandleAndProgress(newValue: number) { - const max = this._effectiveMax; - const min = this._effectiveMin; + const max = this.max; + const min = this.min; this._progressPercentage = (newValue - min) / (max - min); 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; @@ -452,38 +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.updateStateStorageAndFireInputEvent("value"); - } - } - - _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.tooltipValue = this._getCustomLabel(newValue!) || this.value.toFixed(stepPrecision); this.updateStateStorageAndFireInputEvent("value"); } } diff --git a/packages/main/src/SliderTemplate.tsx b/packages/main/src/SliderTemplate.tsx index c0e96c6181e5..9df1c6804c90 100644 --- a/packages/main/src/SliderTemplate.tsx +++ b/packages/main/src/SliderTemplate.tsx @@ -10,22 +10,22 @@ const _handlePosition = (min: number, max: number, value: number) => { }; const handle = (slider: Slider) => { - const position = _handlePosition(slider._effectiveMin, slider._effectiveMax, slider.value); + const position = _handlePosition(slider.min, slider.max, slider.value); return ( <> ( 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

+ + + + + + + +

Slider Custom Tickmarks — Design Review

+

Interactive examples for the open design questions. Try each slider with mouse, keyboard (Arrow, Ctrl+Arrow, Home/End), and observe the tooltip behavior.

+ + +
+

1. Tooltip for in-between values

+
Drag the handle between labeled tickmarks. The tooltip shows the raw number (e.g. "26") when not on a tickmark, and the label (e.g. "Room Temp") when exactly on one.
+ +
+ Question: When the handle is between labeled positions (e.g. value=30, between "Room Temp" at 25 and "Warm" at 50), showing the raw number is correct? Or should we show the nearest label, both neighbors, or something else? +
+
+ + +
+

2. Snap / magnetic tickmarks

+
Drag the handle freely — it moves by step=1 with no snapping. Compare with the second slider where step matches tickmark spacing (effectively snapping).
+

Free movement (step=1, 100 positions):

+ +

Effectively snapping (step=33, only lands on tickmarks):

+ +
+ Question: Should there be a subtle "magnetic" snap zone (e.g. within 2-3px of a tickmark) that pulls the handle to the labeled value for easier selection? Or is free movement preferred with the app controlling step to achieve snap-like behavior? +
+
+ + +
+

3. Keyboard large-step behavior (Ctrl+Arrow)

+
Focus the slider and press Ctrl+Right Arrow. Currently it jumps by 10 (1/10th of range). The tickmarks are at 0, 33, 66, 100.
+ +
+ Question: Should Ctrl+Arrow jump to the next/previous tickmark value instead (0→33→66→100)? That would make labeled points reachable in one keystroke. Or keep the standard 1/10th jump? +
+
+ + +
+

4. Minor ticks between labeled tickmarks

+
First slider: only custom tickmarks render. Second slider: showTickmarks is also set, but step-based ticks don't show alongside custom ones.
+

Custom tickmarks only:

+ +

With show-tickmarks also set:

+ +
+ Question: When custom labeled tickmarks are defined with a fine step, should step-based minor ticks also be visible between the labeled major ticks? Or are only the labeled positions shown? +
+
+ + +
+

5. Label density and overflow

+
This slider has many tickmarks with long labels. Resize the browser window narrower to see how labels behave when space is limited.
+ +
+ Question: When labels overlap, the current behavior hides them (only first/last remain). Is this acceptable? Should we truncate, rotate, or apply other responsive rules? +
+
+ + +
+

6. Range Slider: in-range label emphasis

+
The tickmarks between the two handles get "in-range" coloring on the tick line. Observe the labels — they have no visual change.
+ +
+ Question: Should tickmark labels within the selected range get visual emphasis (bold, different color) to reinforce the selection? Or keep them neutral? +
+
+ + + +