diff --git a/packages/main/src/SliderEvolution.ts b/packages/main/src/SliderEvolution.ts new file mode 100644 index 000000000000..a50aa4774bfb --- /dev/null +++ b/packages/main/src/SliderEvolution.ts @@ -0,0 +1,123 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import SliderEvolutionTemplate from "./SliderEvolutionTemplate.js"; +import SliderScale, { SliderScaleOrientation } from "./SliderScale.js"; +import SliderHandle from "./SliderHandle.js"; +import styles from "./generated/themes/SliderEvolution.css.js"; + +@customElement({ + tag: "ui5-slider-evolution", + renderer: jsxRenderer, + template: SliderEvolutionTemplate, + styles, + dependencies: [SliderScale, SliderHandle], +}) +class SliderEvolution extends UI5Element { + @property({ type: Boolean, noAttribute: true }) + _pressed = false; + + @property({ type: Number }) + value = 0; + + @property({ type: Number }) + min = 0; + + @property({ type: Number }) + max = 100; + + @property({ type: Number }) + step = 1; + + @property({ type: Boolean }) + showTickmarks = false; + + @property({ type: Boolean }) + showTickmarkLabels = false; + + @property() + orientation: `${SliderScaleOrientation}` = "Horizontal"; + + get _handlePosition() { + const range = this.max - this.min; + const position = ((this.value - this.min) / range) * 100; + return position; + } + + _onmousedown = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + this._pressed = true; + + if (!this.getDomRef()?.contains(target) || !target.hasAttribute("ui5-slider-handle")) { + this._updateValue(e); + } + + document.addEventListener("mouseup", this._onmouseup); + document.addEventListener("mousemove", this._onmousemove); + }; + + _onmouseup = () => { + this._pressed = false; + document.removeEventListener("mouseup", this._onmouseup); + document.removeEventListener("mousemove", this._onmousemove); + }; + + _onmousemove = (e: MouseEvent) => { + if (this._pressed) { + this._updateValue(e); + } + }; + + _updateValue(e: MouseEvent) { + const rect = this.getBoundingClientRect(); + let percentage = 0; + + if (this.orientation === SliderScaleOrientation.Horizontal) { + if (this.effectiveDir === "rtl") { + const x = e.clientX - rect.left; + percentage = 1 - x / rect.width; + } else { + const x = e.clientX - rect.left; + percentage = x / rect.width; + } + } else { + const y = e.clientY - rect.top; + percentage = 1 - y / rect.height; + } + + let value = this.min + percentage * (this.max - this.min); + + value = Math.round(value / this.step) * this.step; + + if (value < this.min) { + value = this.min; + } + if (value > this.max) { + value = this.max; + } + + this.value = value; + } + + keydown(e: KeyboardEvent) { + if (e.key === "ArrowRight" || e.key === "ArrowUp") { + this.value = Math.min(this.value + this.step, this.max); + e.preventDefault(); + } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + this.value = Math.max(this.value - this.step, this.min); + e.preventDefault(); + } else if (e.key === "Home") { + this.value = this.min; + e.preventDefault(); + } else if (e.key === "End") { + this.value = this.max; + e.preventDefault(); + } + }; +} + +SliderEvolution.define(); + +export default SliderEvolution; diff --git a/packages/main/src/SliderEvolutionTemplate.tsx b/packages/main/src/SliderEvolutionTemplate.tsx new file mode 100644 index 000000000000..481eeeeb62c3 --- /dev/null +++ b/packages/main/src/SliderEvolutionTemplate.tsx @@ -0,0 +1,58 @@ +import type SliderEvolution from "./SliderEvolution.js"; +import SliderScale from "./SliderScale.js"; +import SliderHandle from "./SliderHandle.js"; +import { getScopedVarName } from "@ui5/webcomponents-base/dist/CustomElementsScopeUtils.js"; + +export default function SliderEvolutionTemplate(this: SliderEvolution) { + const _handlePosition = () => { + const range = this.max - this.min; + const position = ((this.value - this.min) / range) * 100; + return position; + }; + + const _handleVerticalPosition = () => { + const range = this.max - this.min; + const position = ((this.value - this.min) / range) * 100; + return position; + }; + + const calcHandlePosition = () => { + if (this.orientation === "Vertical") { + return `calc(${_handleVerticalPosition()}% - calc(var(${getScopedVarName("--_ui5_slider_handle_height")}) / 2))`; + } + + if (this.effectiveDir === "rtl") { + return `calc(${100 - _handlePosition()}% - calc(var(${getScopedVarName("--_ui5_slider_handle_width")}) / 2))`; + } + + return `calc(${_handlePosition()}% - calc(var(${getScopedVarName("--_ui5_slider_handle_width")}) / 2))`; + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/main/src/SliderHandle.ts b/packages/main/src/SliderHandle.ts new file mode 100644 index 000000000000..08d7bd69808c --- /dev/null +++ b/packages/main/src/SliderHandle.ts @@ -0,0 +1,43 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import SliderHandleTemplate from "./SliderHandleTemplate.js"; +import styles from "./generated/themes/SliderHandle.css.js"; +import type { SliderScaleOrientation } from "./SliderScale.js"; + +@customElement({ + tag: "ui5-slider-handle", + renderer: jsxRenderer, + template: SliderHandleTemplate, + styles, +}) +class SliderHandle extends UI5Element { + @property({ type: Number }) + value = 0; + + @property({ type: Number }) + min = 0; + + @property({ type: Number }) + max = 100; + + @property({ type: Boolean }) + disabled = false; + + @property({ type: Boolean }) + active = false; + + @property() + orientation: `${SliderScaleOrientation}` = "Horizontal"; + + get _handlePosition() { + const range = this.max - this.min; + const position = ((this.value - this.min) / range) * 100; + return position; + } +} + +SliderHandle.define(); + +export default SliderHandle; diff --git a/packages/main/src/SliderHandleTemplate.tsx b/packages/main/src/SliderHandleTemplate.tsx new file mode 100644 index 000000000000..eaf9beac5a1f --- /dev/null +++ b/packages/main/src/SliderHandleTemplate.tsx @@ -0,0 +1,15 @@ +import directionArrows from "@ui5/webcomponents-icons/dist/direction-arrows.js"; +import Icon from "./Icon.js"; +import type SliderHandle from "./SliderHandle.js"; + +export default function SliderHandleTemplate(this: SliderHandle) { + return ( +
+ +
+ ); +} diff --git a/packages/main/src/SliderScale.ts b/packages/main/src/SliderScale.ts new file mode 100644 index 000000000000..61f4ba777880 --- /dev/null +++ b/packages/main/src/SliderScale.ts @@ -0,0 +1,226 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import SliderScaleTemplate from "./SliderScaleTemplate.js"; + +import SliderScaleCss from "./generated/themes/SliderScale.css.js"; + +type Tickmark = { + value: number; + label?: string; +}; + +enum SliderScaleOrientation { + /** + * Horizontal orientation + * @public + */ + Horizontal = "Horizontal", + + /** + * Vertical orientation + * @public + */ + Vertical = "Vertical", +} + +@customElement({ + tag: "ui5-slider-scale", + renderer: jsxRenderer, + styles: SliderScaleCss, + template: SliderScaleTemplate, +}) +class SliderScale extends UI5Element { + @property({ type: Number }) + startValue = 0; + + @property({ type: Number }) + endValue = 100; + + @property({ type: Number }) + min = 0; + + @property({ type: Number }) + max = 100; + + @property({ type: Number }) + step = 1; + + @property({ type: Boolean }) + showTickmarks = false; + + @property({ type: Boolean }) + showTickmarkLabels = false; + + @property() + orientation: `${SliderScaleOrientation}` = "Horizontal"; + + @property({ type: Array }) + tickmarks: Array = []; + + /** + * @private + */ + @property({ type: Number }) + _labelInterval = 1; + + _resizeHandler: ResizeObserverCallback; + _notResized = true; + + static get MIN_LABEL_DISTANCE() { + return 16; + } + + constructor() { + super(); + this._resizeHandler = this._handleResize.bind(this); + } + + onEnterDOM() { + ResizeHandler.register(this, this._resizeHandler); + } + + onExitDOM() { + ResizeHandler.deregister(this, this._resizeHandler); + } + + onAfterRendering() { + if (this._notResized) { + this._handleResize(); + } + } + + /** + * Handles resize to determine optimal label interval + * @private + */ + _handleResize() { + if (!this.showTickmarkLabels) { + return; + } + + this._notResized = false; + + const width = this.orientation === SliderScaleOrientation.Horizontal + ? this.getBoundingClientRect().width + : this.getBoundingClientRect().height; + const totalTickmarks = this._allTickmarks.length; + + if (totalTickmarks <= 1) { + this._labelInterval = 1; + return; + } + + // Start with showing all labels (interval = 1) + let interval = 1; + let visibleLabelCount = Math.floor((totalTickmarks - 1) / interval) + 1; + let spaceBetweenLabels = width / (visibleLabelCount - 1); + + // Keep doubling the interval until we have enough space + while ( + spaceBetweenLabels < SliderScale.MIN_LABEL_DISTANCE && interval < totalTickmarks + ) { + interval *= 2; + visibleLabelCount = Math.floor((totalTickmarks - 1) / interval) + 1; + spaceBetweenLabels = width / (visibleLabelCount - 1); + } + + this._labelInterval = interval; + } + + get _progressStyle() { + const range = this.max - this.min; + const start = ((this.startValue - this.min) / range) * 100; + const end = ((this.endValue - this.min) / range) * 100; + + if (this.orientation === SliderScaleOrientation.Vertical) { + return { + top: "auto", + bottom: `${start}%`, + height: `${end - start}%`, + }; + } + + return { + insetInlineStart: `${start}%`, + width: `${end - start}%`, + }; + } + + get _allTickmarks() { + // If custom tickmarks are provided, use them + if (this.tickmarks.length > 0) { + return this.tickmarks; + } + + // Otherwise, generate tickmarks based on step + if (!this.showTickmarks) { + return []; + } + + const values = []; + for (let value = this.min; value <= this.max; value += this.step) { + values.push({ value }); + } + return values; + } + + get _tickmarks() { + const allTickmarks = this._allTickmarks; + + if (allTickmarks.length === 0) { + return []; + } + + // If labels are not shown, show all tickmarks without labels + if (!this.showTickmarkLabels) { + return allTickmarks.map(tm => { + const value = tm.value; + const isInRange = value >= this.startValue && value <= this.endValue; + const position = ((value - this.min) / (this.max - this.min)) * 100; + + return { + value, + label: undefined, + isInRange, + position, + showLabel: false, + }; + }); + } + + // If labels are shown, only show tickmarks that have labels + const tickmarksWithLabels:Array<{value: number; label: string; isInRange: boolean; position: number; showLabel: boolean}> = []; + + allTickmarks.forEach((tm, index) => { + const value = tm.value; + const isFirstOrLast = index === 0 || index === allTickmarks.length - 1; + const isIntervalMatch = index % this._labelInterval === 0; + + // Only include this tickmark if its label should be shown + if (isFirstOrLast || isIntervalMatch) { + const isInRange = value >= this.startValue && value <= this.endValue; + const position = ((value - this.min) / (this.max - this.min)) * 100; + + tickmarksWithLabels.push({ + value, + label: tm.label || value.toString(), + isInRange, + position, + showLabel: true, + }); + } + }); + + return tickmarksWithLabels; + } +} + +SliderScale.define(); + +export default SliderScale; +export { SliderScaleOrientation }; +export type { Tickmark }; diff --git a/packages/main/src/SliderScaleTemplate.tsx b/packages/main/src/SliderScaleTemplate.tsx new file mode 100644 index 000000000000..b6cd1d15ad71 --- /dev/null +++ b/packages/main/src/SliderScaleTemplate.tsx @@ -0,0 +1,31 @@ +import type SliderScale from "./SliderScale.js"; + +export default function SliderScaleTemplate(this: SliderScale) { + return ( +
+ {this._tickmarks.length > 0 && ( +
+ {this._tickmarks.map(tick => ( +
+ {tick.label && tick.showLabel && ( + + {tick.label} + + )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 77c873528352..c1fbe786b6b5 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -97,6 +97,9 @@ import Select from "./Select.js"; import Option from "./Option.js"; import CustomOption from "./OptionCustom.js"; import Slider from "./Slider.js"; +import SliderScale from "./SliderScale.js"; +import SliderHandle from "./SliderHandle.js"; +import SliderEvolution from "./SliderEvolution.js"; import SplitButton from "./SplitButton.js"; import StepInput from "./StepInput.js"; import RangeSlider from "./RangeSlider.js"; diff --git a/packages/main/src/themes/SliderEvolution.css b/packages/main/src/themes/SliderEvolution.css new file mode 100644 index 000000000000..fe2a299f7c58 --- /dev/null +++ b/packages/main/src/themes/SliderEvolution.css @@ -0,0 +1,28 @@ +:host { + display: inline-block; + width: 100%; + height: 3rem; + box-sizing: border-box; + user-select: none; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0; +} + +.ui5-slider-evolution-root { + position: relative; + width: 100%; + height: 3rem; + cursor: pointer; +} + +:host([orientation="Vertical"]) { + width: 3rem; + height: 100%; + padding: 0 0.5rem; + + .ui5-slider-evolution-root { + width: 3rem; + height: 100%; + } +} diff --git a/packages/main/src/themes/SliderHandle.css b/packages/main/src/themes/SliderHandle.css new file mode 100644 index 000000000000..06218e3d3c0d --- /dev/null +++ b/packages/main/src/themes/SliderHandle.css @@ -0,0 +1,48 @@ +:host { + background: var(--_ui5_slider_handle_background); + border: var(--_ui5_slider_handle_border); + border-radius: var(--_ui5_slider_handle_border_radius); + position: absolute; + outline: none; + height: var(--_ui5_slider_handle_height); + width: var(--_ui5_slider_handle_width); + box-sizing: var(--_ui5_slider_handle_box_sizing); + display: flex; + justify-content: center; + align-items: center; + z-index: 2; + top: 0; + cursor: pointer; +} + +.ui5-slider-handle-container { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + outline: none; +} + +[slider-icon] { + display: var(--_ui5_slider_handle_icon_display); + color: var(--sapContent_Selected_ForegroundColor); + width: var(--_ui5_slider_handle_icon_size); + height: var(--_ui5_slider_handle_icon_size); +} + +:host(:focus), +:host([active]), +:host(:active) { + [slider-icon] { + display: none; + } + + background-color: transparent; + border: var(--_ui5_slider_handle_focus_border); +} + +:host([orientation="Vertical"]) { + /* rotate */ + transform: rotate(90deg); +} \ No newline at end of file diff --git a/packages/main/src/themes/SliderScale.css b/packages/main/src/themes/SliderScale.css new file mode 100644 index 000000000000..d078f4503f79 --- /dev/null +++ b/packages/main/src/themes/SliderScale.css @@ -0,0 +1,136 @@ +:host { + height: 2rem; + position: relative; + width: 100%; + box-sizing: border-box; + display: inline-flex; + justify-content: center; + align-items: center; +} + +.ui5-slider-scale-root { + height: .25rem; +} + +:host([orientation="Vertical"]) { + width: 2rem; + height: 100%; + + .ui5-slider-scale-root { + box-sizing: border-box; + height: 100%; + width: 0.25rem; + background-color: var(--sapSlider_Background); + position: relative; + } + + .ui5-slider-scale-progress { + width: 100%; + height: auto; + } + + .ui5-slider-scale-root:before { + left: 50%; + top: -12px; + right: auto; + transform: translateX(-50%); + } + + .ui5-slider-scale-root:after { + left: 50%; + right: auto; + top: auto; + bottom: -12px; + transform: translateX(-50%); + } + + .ui5-slider-scale-tickmark-label { + margin-top: 0; + margin-left: 14px; + top: 50%; + left: 100%; + transform: translateY(-50%); + } + + .ui5-slider-scale-tickmark { + height: auto; + width: .5rem; + margin-top: 0; + margin-left: 0; + border-left: none; + border-top: 0.0625rem solid var(--sapField_BorderColor); + } +} + +.ui5-slider-scale-root { + box-sizing: border-box; + border-radius: 0.25rem; + width: 100%; + background-color: var(--sapSlider_Background); + position: relative; +} + +.ui5-slider-scale-tickmarks-container { + position: absolute; + width: 100%; + height: 100%; + top: 0; + z-index: 1; +} + +.ui5-slider-scale-tickmark { + position: absolute; + height: .5rem; + margin-top: -.125rem; + transform: translateX(-50%); + border-left: 0.0625rem solid var(--sapField_BorderColor); +} + +.ui5-slider-scale-tickmark-in-range { + border-left-color: var(--sapSlider_Selected_BorderColor); +} + +.ui5-slider-scale-tickmark-label { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 0.875rem; + font-size: var(--sapFontSmallSize); + color: var(--sapContent_LabelColor); + white-space: nowrap; +} + +.ui5-slider-scale-progress { + position: absolute; + height: 100%; + top: 0; + background-color: var(--sapSlider_Selected_Background); + z-index: 2; +} + +.ui5-slider-scale-root::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: .5rem; + height: .5rem; + background-color: var(--sapContent_MeasureIndicatorColor); + border-radius: 50%; + left: -12px; + top: -50%; +} + +.ui5-slider-scale-root::after { + content: ""; + position: absolute; + top: 50%; + right: 0; + width: .5rem; + height: .5rem; + background-color: var(--sapContent_MeasureIndicatorColor); + border-radius: 50%; + right: -12px; + top: -50%; +} \ No newline at end of file diff --git a/packages/main/test/pages/SliderScale.html b/packages/main/test/pages/SliderScale.html new file mode 100644 index 000000000000..e4925756be93 --- /dev/null +++ b/packages/main/test/pages/SliderScale.html @@ -0,0 +1,90 @@ + + + + + + + UI5 Slider + + + + + + + + + + + + + + + +
+

Slider Evolution examples

+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ + + +
+ + + + + + + + + + + +