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" } + ];