From 672472e4f8109a1246e1d3af49c838e34af37939 Mon Sep 17 00:00:00 2001 From: Marko Oleksiyenko Date: Wed, 12 Feb 2025 10:32:52 +0100 Subject: [PATCH] feat(slider): Add the ticks variant --- .../bootstrap/src/agnos-ui-angular.module.ts | 9 +- .../src/components/slider/slider.component.ts | 105 +++- angular/bootstrap/src/index.ts | 1 + .../app/samples/slider/right-to-left.route.ts | 2 +- .../src/app/samples/slider/ticks.route.ts | 39 ++ .../src/app/samples/slider/vertical.route.ts | 12 +- .../src/components/slider/slider.ts | 18 +- core-bootstrap/src/scss/_variables.scss | 10 + core-bootstrap/src/scss/slider.scss | 58 +- core/src/components/slider/slider.spec.ts | 365 ++++++++++++ core/src/components/slider/slider.ts | 178 +++++- .../components/slider/examples/+page.svelte | 4 + .../bootstrap-slider-accessibility.html | 1 + .../bootstrap-slider-custom.html | 2 + .../bootstrap-slider-default.html | 3 + .../bootstrap-slider-playground.html | 1 + .../bootstrap-slider-range.html | 2 + .../bootstrap-slider-right-to-left.html | 147 ++++- .../bootstrap-slider-ticks.html | 547 ++++++++++++++++++ .../bootstrap-slider-vertical.html | 145 ++++- .../daisyui-slider-default.html | 1 + e2e/slider/slider.e2e-spec.ts | 69 +++ e2e/ssr.ssr-e2e-spec.ts-snapshots/ssr.html | 1 + page-objects/lib/slider.po.ts | 31 + .../src/components/slider/slider.tsx | 69 ++- .../samples/slider/Right-to-left.route.tsx | 2 +- .../bootstrap/samples/slider/Ticks.route.tsx | 59 ++ .../samples/slider/Vertical.route.tsx | 12 +- .../src/components/slider/Slider.svelte | 8 +- .../slider/SliderDefaultStructure.svelte | 3 + .../slider/SliderDefaultTick.svelte | 38 ++ .../samples/slider/Right-to-left.route.svelte | 2 +- .../samples/slider/Ticks.route.svelte | 25 + .../samples/slider/Vertical.route.svelte | 2 +- 34 files changed, 1931 insertions(+), 40 deletions(-) create mode 100644 angular/demo/bootstrap/src/app/samples/slider/ticks.route.ts create mode 100644 e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-slider-ticks.html create mode 100644 react/demo/src/bootstrap/samples/slider/Ticks.route.tsx create mode 100644 svelte/bootstrap/src/components/slider/SliderDefaultTick.svelte create mode 100644 svelte/demo/src/bootstrap/samples/slider/Ticks.route.svelte diff --git a/angular/bootstrap/src/agnos-ui-angular.module.ts b/angular/bootstrap/src/agnos-ui-angular.module.ts index cfa93e91ba..4a22f42786 100644 --- a/angular/bootstrap/src/agnos-ui-angular.module.ts +++ b/angular/bootstrap/src/agnos-ui-angular.module.ts @@ -29,7 +29,13 @@ import { AccordionBodyDirective, AccordionItemStructureDirective, } from './components/accordion/accordion.component'; -import {SliderComponent, SliderHandleDirective, SliderLabelDirective, SliderStructureDirective} from './components/slider/slider.component'; +import { + SliderComponent, + SliderHandleDirective, + SliderLabelDirective, + SliderStructureDirective, + SliderTickDirective, +} from './components/slider/slider.component'; import {ProgressbarComponent, ProgressbarBodyDirective, ProgressbarStructureDirective} from './components/progressbar/progressbar.component'; import {ToastBodyDirective, ToastComponent, ToastHeaderDirective, ToastStructureDirective} from './components/toast/toast.component'; import {CollapseDirective, CollapseTriggerDirective} from './components/collapse'; @@ -77,6 +83,7 @@ const components = [ SliderHandleDirective, SliderLabelDirective, SliderStructureDirective, + SliderTickDirective, ProgressbarComponent, ProgressbarStructureDirective, ProgressbarBodyDirective, diff --git a/angular/bootstrap/src/components/slider/slider.component.ts b/angular/bootstrap/src/components/slider/slider.component.ts index 01d6775e32..a85566bc25 100644 --- a/angular/bootstrap/src/components/slider/slider.component.ts +++ b/angular/bootstrap/src/components/slider/slider.component.ts @@ -25,7 +25,7 @@ import { } from '@angular/core'; import {NG_VALUE_ACCESSOR} from '@angular/forms'; import {callWidgetFactory} from '../../config'; -import type {SliderContext, SliderSlotHandleContext, SliderSlotLabelContext, SliderWidget} from './slider.gen'; +import type {SliderContext, SliderSlotHandleContext, SliderSlotLabelContext, SliderSlotTickContext, SliderWidget} from './slider.gen'; import {createSlider} from './slider.gen'; /** @@ -43,11 +43,11 @@ export class SliderLabelDirective { /** * Directive representing a handle for a slider component. * - * This directive uses a template reference to render the {@link SliderSlotLabelContext}. + * This directive uses a template reference to render the {@link SliderSlotHandleContext}. */ @Directive({selector: 'ng-template[auSliderHandle]'}) export class SliderHandleDirective { - public templateRef = inject(TemplateRef); + public templateRef = inject(TemplateRef); static ngTemplateContextGuard(_dir: SliderHandleDirective, context: unknown): context is SliderSlotHandleContext { return true; } @@ -107,6 +107,73 @@ class SliderDefaultHandleSlotComponent { */ export const sliderDefaultSlotHandle: SlotContent = new ComponentTemplate(SliderDefaultHandleSlotComponent, 'handle'); +/** + * Directive representing a tick for a slider component. + * + * This directive uses a template reference to render the {@link SliderSlotTickContext}. + */ +@Directive({selector: 'ng-template[auSliderTick]'}) +export class SliderTickDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: SliderTickDirective, context: unknown): context is SliderSlotTickContext { + return true; + } +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective], + template: ` + + @if (item.displayLabel) { + + {{ item.value }} + + } + + @if (!item.selected) { + + + + } @else { + + + + + } + + + `, +}) +class SliderDefaultTickSlotComponent { + readonly tick = viewChild.required>('tick'); +} + +/** + * A constant representing the default slot tick for the slider component. + */ +export const sliderDefaultSlotTick: SlotContent = new ComponentTemplate(SliderDefaultTickSlotComponent, 'tick'); + /** * Directive that provides structure for the slider component. * @@ -148,6 +215,9 @@ export class SliderStructureDirective { } } + @for (tick of state.ticks(); track tick.position) { + + } @for (item of state.sortedHandles(); track item.id; let i = $index) { @if (state.showValueLabels() && !state.combinedLabelDisplay()) { @@ -296,6 +366,27 @@ export class SliderComponent extends BaseWidgetDirective { */ readonly vertical = input(undefined, {alias: 'auVertical', transform: auBooleanAttribute}); + /** + * If `true` the ticks are displayed on the slider + * + * @defaultValue `false` + */ + readonly showTicks = input(undefined, {alias: 'auShowTicks', transform: auBooleanAttribute}); + + /** + * Unit value between the ticks + * + * @defaultValue `0` + */ + readonly tickStep = input(undefined, {alias: 'auTickStep', transform: auNumberAttribute}); + + /** + * If `true` the tick values are displayed on the slider + * + * @defaultValue `true` + */ + readonly showTickValues = input(undefined, {alias: 'auShowTickValues', transform: auBooleanAttribute}); + /** * An event emitted when slider values are changed * @@ -331,6 +422,12 @@ export class SliderComponent extends BaseWidgetDirective { readonly handle = input>(undefined, {alias: 'auHandle'}); readonly slotHandleFromContent = contentChild(SliderHandleDirective); + /** + * Slot to change the ticks + */ + readonly tick = input>(undefined, {alias: 'auTick'}); + readonly slotTickFromContent = contentChild(SliderTickDirective); + constructor() { super( callWidgetFactory({ @@ -339,6 +436,7 @@ export class SliderComponent extends BaseWidgetDirective { defaultConfig: { structure: sliderDefaultSlotStructure, handle: sliderDefaultSlotHandle, + tick: sliderDefaultSlotTick, }, events: { onValuesChange: (event) => { @@ -354,6 +452,7 @@ export class SliderComponent extends BaseWidgetDirective { structure: this.slotStructureFromContent()?.templateRef, handle: this.slotHandleFromContent()?.templateRef, label: this.slotLabelFromContent()?.templateRef, + tick: this.slotTickFromContent()?.templateRef, }), }), ); diff --git a/angular/bootstrap/src/index.ts b/angular/bootstrap/src/index.ts index e751d93ea6..aad375e6da 100644 --- a/angular/bootstrap/src/index.ts +++ b/angular/bootstrap/src/index.ts @@ -73,6 +73,7 @@ export type { SliderState, SliderWidget, SliderHandle, + SliderTick, HandleDisplayOptions, ProgressDisplayOptions, SliderDirectives, diff --git a/angular/demo/bootstrap/src/app/samples/slider/right-to-left.route.ts b/angular/demo/bootstrap/src/app/samples/slider/right-to-left.route.ts index 7f4d5431a1..80a349c737 100644 --- a/angular/demo/bootstrap/src/app/samples/slider/right-to-left.route.ts +++ b/angular/demo/bootstrap/src/app/samples/slider/right-to-left.route.ts @@ -6,7 +6,7 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; imports: [SliderComponent, ReactiveFormsModule, FormsModule], template: `

Horizontal slider

-
+

Vertical slider

diff --git a/angular/demo/bootstrap/src/app/samples/slider/ticks.route.ts b/angular/demo/bootstrap/src/app/samples/slider/ticks.route.ts new file mode 100644 index 0000000000..a627ba1440 --- /dev/null +++ b/angular/demo/bootstrap/src/app/samples/slider/ticks.route.ts @@ -0,0 +1,39 @@ +import {SliderComponent} from '@agnos-ui/angular-bootstrap'; +import {Component, signal} from '@angular/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; + +@Component({ + imports: [SliderComponent, ReactiveFormsModule, FormsModule], + template: ` +

With intermediate steps

+
+
+

Ticks as steps

+
+ +
+ + +
+ +

Without tick labels

+
+

If showTickValues is set to false automatically put the min/max/current label display to true

+ `, +}) +export default class TicksSliderComponent { + readonly sliderControl = new FormControl([30]); + + readonly sliderRangeControl = new FormControl([30, 70]); + readonly disabledToggle = signal(false); + + readonly sliderRangeControlLabels = new FormControl([30, 70]); + + handleDisabled() { + if (this.disabledToggle()) { + this.sliderRangeControl.disable(); + } else { + this.sliderRangeControl.enable(); + } + } +} diff --git a/angular/demo/bootstrap/src/app/samples/slider/vertical.route.ts b/angular/demo/bootstrap/src/app/samples/slider/vertical.route.ts index 3eb9fdeb43..de5026491f 100644 --- a/angular/demo/bootstrap/src/app/samples/slider/vertical.route.ts +++ b/angular/demo/bootstrap/src/app/samples/slider/vertical.route.ts @@ -12,7 +12,17 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
Form control values: {{ sliderControlRangeValues()?.join(', ') }}
-
+
From control value: {{ sliderControlValues() }}
diff --git a/core-bootstrap/src/components/slider/slider.ts b/core-bootstrap/src/components/slider/slider.ts index 7f0d7f5113..a0b516b1c6 100644 --- a/core-bootstrap/src/components/slider/slider.ts +++ b/core-bootstrap/src/components/slider/slider.ts @@ -1,4 +1,4 @@ -import type {SliderDirectives, SliderHandle, SliderProps as CoreProps, SliderState as CoreState} from '@agnos-ui/core/components/slider'; +import type {SliderDirectives, SliderHandle, SliderProps as CoreProps, SliderState as CoreState, SliderTick} from '@agnos-ui/core/components/slider'; import {createSlider as createCoreSlider, getSliderDefaultConfig as getCoreDefaultConfig} from '@agnos-ui/core/components/slider'; import {extendWidgetProps} from '@agnos-ui/core/services/extendWidget'; import type {SlotContent, Widget, WidgetFactory, WidgetSlotContext} from '@agnos-ui/core/types'; @@ -31,6 +31,16 @@ export interface SliderSlotHandleContext extends SliderContext { item: SliderHandle; } +/** + * Represents the context for a slider tick slot + */ +export interface SliderSlotTickContext extends SliderContext { + /** + * tick context + */ + tick: SliderTick; +} + interface SliderExtraProps { /** * Slot to change the default display of the slider @@ -51,6 +61,11 @@ interface SliderExtraProps { * Slot to change the handlers */ handle: SlotContent; + + /** + * Slot to change the ticks + */ + tick: SlotContent; } /** @@ -71,6 +86,7 @@ const defaultConfigExtraProps: SliderExtraProps = { structure: undefined, label: ({value}: SliderSlotLabelContext) => '' + value, handle: undefined, + tick: undefined, }; /** diff --git a/core-bootstrap/src/scss/_variables.scss b/core-bootstrap/src/scss/_variables.scss index bc1b0738a8..0d543bb983 100644 --- a/core-bootstrap/src/scss/_variables.scss +++ b/core-bootstrap/src/scss/_variables.scss @@ -28,6 +28,12 @@ $au-slider-handle-border-radius: 50% !default; $au-slider-handle-outline: none !default; $au-slider-handle-focus-box-shadow: 0 0 0 $au-focus-ring-width $au-focus-ring-color !default; $au-slider-handle-focus-hover-box-shadow: 0 0 0 $au-focus-ring-width $au-focus-ring-color !default; +$au-slider-tick-primary-size: 1rem !default; +$au-slider-tick-secondary-size: 1rem !default; +$au-slider-tick-neutral-color: var(--#{$prefix}light-emphasis, #666666) !default; +$au-slider-tick-disabled-color: var(--#{$prefix}dark-bg-subtle, #ced4da) !default; +$au-slider-tick-selected-color: var(--#{$prefix}primary, #0d6efd) !default; +$au-slider-tick-label-translate-vertical: translateY(75%) !default; $au-slider-progress-color: var(--#{$prefix}primary, #0d6efd) !default; $au-slider-progress-height: 0.25rem !default; $au-slider-progress-vertical-transform: rotate(90deg) !default; @@ -59,11 +65,15 @@ $au-slider-disabled-cursor: not-allowed !default; $au-slider-bar-size-sm: 0.2rem !default; $au-slider-handle-size-sm: 1rem !default; +$au-slider-tick-primary-size-sm: 0.75rem !default; +$au-slider-tick-secondary-size-sm: 0.75rem !default; $au-slider-font-size-sm: 0.875rem !default; $au-slider-offset-sm: 0rem !default; $au-slider-bar-size-lg: 0.3125rem !default; $au-slider-handle-size-lg: 1.5rem !default; +$au-slider-tick-primary-size-lg: 1.25rem !default; +$au-slider-tick-secondary-size-lg: 1.25rem !default; $au-slider-font-size-lg: 1.125rem !default; $au-slider-offset-lg: 0rem !default; // scss-docs-end slider-vars diff --git a/core-bootstrap/src/scss/slider.scss b/core-bootstrap/src/scss/slider.scss index ce5a2dbb25..ce8bceea00 100644 --- a/core-bootstrap/src/scss/slider.scss +++ b/core-bootstrap/src/scss/slider.scss @@ -3,7 +3,7 @@ /// Mixin used to redefine css variable for different size of slider /// In particular the size bar and the size of the handle /// The offset is used to add some space between the text and the slider -@mixin setSliderSize($barSize, $handleSize, $fontSize, $offset) { +@mixin setSliderSize($barSize, $handleSize, $fontSize, $offset, $tickPrimarySize, $tickSecondarySize) { --#{variables.$prefix}slider-font-size: #{$fontSize}; --#{variables.$prefix}slider-border-radius: calc(#{$barSize} / 2); @@ -15,8 +15,11 @@ --#{variables.$prefix}slider-vertical-margin-inline-end: calc((#{$handleSize} - #{$barSize}) / 2 + #{$offset} + 3ch + #{$offset}); --#{variables.$prefix}slider-handle-size: #{$handleSize}; + --#{variables.$prefix}slider-tick-primary-size: #{$tickPrimarySize}; + --#{variables.$prefix}slider-tick-secondary-size: #{$tickSecondarySize}; --#{variables.$prefix}slider-label-margin-block-start: calc(-1 * (#{$fontSize} * 1.5 + (#{$handleSize} - #{$barSize}) / 2)); + --#{variables.$prefix}slider-tick-label-margin-block-start: calc(-1 * (#{$fontSize} * 1.5 + (#{$tickPrimarySize} - #{$barSize}) / 2)); --#{variables.$prefix}slider-label-vertical-margin-inline-start: calc((#{$handleSize} - #{$barSize}) / 2 + #{$barSize} + #{$offset}); } @@ -46,6 +49,15 @@ --#{variables.$prefix}slider-handle-focus-box-shadow: #{variables.$au-slider-handle-focus-box-shadow}; --#{variables.$prefix}slider-handle-focus-hover-box-shadow: #{variables.$au-slider-handle-focus-hover-box-shadow}; + --#{variables.$prefix}slider-tick-primary-size: #{variables.$au-slider-tick-primary-size}; + --#{variables.$prefix}slider-tick-secondary-size: #{variables.$au-slider-tick-secondary-size}; + + --#{variables.$prefix}slider-tick-neutral-color: #{variables.$au-slider-tick-neutral-color}; + --#{variables.$prefix}slider-tick-selected-color: #{variables.$au-slider-tick-selected-color}; + --#{variables.$prefix}slider-tick-disabled-color: #{variables.$au-slider-tick-disabled-color}; + + --#{variables.$prefix}slider-tick-label-translate-vertical: #{variables.$au-slider-tick-label-translate-vertical}; + --#{variables.$prefix}slider-progress-color: #{variables.$au-slider-progress-color}; --#{variables.$prefix}slider-progress-height: #{variables.$au-slider-progress-height}; --#{variables.$prefix}slider-progress-vertical-transform: #{variables.$au-slider-progress-vertical-transform}; @@ -89,7 +101,9 @@ variables.$au-slider-bar-size-lg, variables.$au-slider-handle-size-lg, variables.$au-slider-font-size-lg, - variables.$au-slider-offset-lg + variables.$au-slider-offset-lg, + variables.$au-slider-tick-primary-size-lg, + variables.$au-slider-tick-secondary-size-lg ); } @@ -98,7 +112,9 @@ variables.$au-slider-bar-size-sm, variables.$au-slider-handle-size-sm, variables.$au-slider-font-size-sm, - variables.$au-slider-offset-sm + variables.$au-slider-offset-sm, + variables.$au-slider-tick-primary-size-sm, + variables.$au-slider-tick-secondary-size-sm ); } @@ -111,6 +127,10 @@ top: calc(50% - var(--#{variables.$prefix}slider-handle-size) / 2); transform: var(--#{variables.$prefix}slider-translate-horizontal); } + .au-slider-tick-horizontal { + top: calc(50% - var(--#{variables.$prefix}slider-tick-primary-size) / 2); + transform: var(--#{variables.$prefix}slider-translate-horizontal); + } } &.au-slider-vertical { @@ -122,6 +142,10 @@ left: calc(50% - var(--#{variables.$prefix}slider-handle-size) / 2); transform: var(--#{variables.$prefix}slider-translate-vertical); } + .au-slider-tick-vertical { + left: calc(50% - var(--#{variables.$prefix}slider-tick-primary-size) / 2); + transform: var(--#{variables.$prefix}slider-translate-vertical); + } } .au-slider-handle { @@ -145,6 +169,30 @@ } } + .au-slider-tick { + position: absolute; + height: var(--#{variables.$prefix}slider-tick-primary-size); + width: var(--#{variables.$prefix}slider-tick-secondary-size); + + // center align the svg along the slider axis + svg { + display: block; + } + } + + .au-slider-tick-label { + position: absolute; + text-wrap: nowrap; + transform: var(--#{variables.$prefix}slider-translate-horizontal); + margin-block-start: var(--#{variables.$prefix}slider-label-margin-block-start); + } + + .au-slider-tick-label-vertical { + position: absolute; + margin-inline-start: var(--#{variables.$prefix}slider-label-vertical-margin-inline-start); + transform: var(--#{variables.$prefix}slider-tick-label-translate-vertical); + } + .au-slider-progress { background-color: var(--#{variables.$prefix}slider-progress-color); border-radius: var(--#{variables.$prefix}slider-border-radius); @@ -235,7 +283,9 @@ &.disabled { cursor: var(--#{variables.$prefix}slider-disabled-cursor); .au-slider-label, - .au-slider-label-vertical { + .au-slider-label-vertical, + .au-slider-tick-label, + .au-slider-tick-label-vertical { color: var(--#{variables.$prefix}slider-disabled-color); } .au-slider-progress, diff --git a/core/src/components/slider/slider.spec.ts b/core/src/components/slider/slider.spec.ts index 8e27042c8c..8f455f117e 100644 --- a/core/src/components/slider/slider.spec.ts +++ b/core/src/components/slider/slider.spec.ts @@ -49,6 +49,8 @@ const defaultState: () => SliderState = () => ({ interactive: true, showMinMaxLabels: true, showValueLabels: true, + showTicks: false, + ticks: [], rtl: false, }); @@ -1362,6 +1364,369 @@ describe(`Slider basic`, () => { }), ); }); + + test(`should compute ticks based on the tickStep input`, () => { + slider.patch({ + stepSize: 1, + tickStep: 25, + showTicks: true, + }); + + const expectedState = defaultState(); + + expect(normalizedState$()).toStrictEqual( + assign(expectedState, { + showTicks: true, + stepSize: 1, + ticks: [ + { + position: 0, + value: 0, + selected: true, + displayLabel: true, + }, + { + position: 25, + value: 25, + selected: false, + displayLabel: true, + }, + { + position: 50, + value: 50, + selected: false, + displayLabel: true, + }, + { + position: 75, + value: 75, + selected: false, + displayLabel: true, + }, + { + position: 100, + value: 100, + selected: false, + displayLabel: true, + }, + ], + maxValueLabelDisplay: false, + showValueLabels: false, + }), + ); + }); + + test(`should compute ticks on the decimal values and properly mark them selected`, () => { + slider.patch({ + min: 0.33, + max: 1.55, + stepSize: 0.2, + showTicks: true, + values: [1], + }); + + const expectedState = defaultState(); + + expect(normalizedState$()).toStrictEqual( + assign(expectedState, { + min: 0.33, + max: 1.55, + stepSize: 0.2, + values: [0.93], + sortedValues: [0.93], + sortedHandles: [ + { + ...defaultHandle, + value: 0.93, + }, + ], + maxValueLabelDisplay: false, + showValueLabels: false, + progressDisplayOptions: [ + { + left: 0, + right: null, + bottom: null, + top: null, + height: 100, + width: 49.180327868852466, + id: 0, + }, + ], + handleDisplayOptions: [ + { + ...expectedState.handleDisplayOptions[0], + left: 49.180327868852466, + }, + ], + showTicks: true, + ticks: [ + { + position: 0, + value: 0.33, + selected: true, + displayLabel: true, + }, + { + position: 16.39344262295082, + value: 0.53, + selected: true, + displayLabel: true, + }, + { + position: 32.78688524590164, + value: 0.73, + selected: true, + displayLabel: true, + }, + { + position: 49.180327868852466, + value: 0.93, + selected: true, + displayLabel: true, + }, + { + position: 65.57377049180327, + value: 1.13, + selected: false, + displayLabel: true, + }, + { + position: 81.9672131147541, + value: 1.33, + selected: false, + displayLabel: true, + }, + { + position: 98.36065573770492, + value: 1.53, + selected: false, + displayLabel: true, + }, + { + position: 100, + value: 1.55, + selected: false, + displayLabel: true, + }, + ], + }), + ); + }); + + test(`should proprely compute tick position for RTL case`, () => { + slider.patch({ + rtl: true, + tickStep: 25, + showTicks: true, + }); + + const expectedState = defaultState(); + + expect(normalizedState$()).toStrictEqual( + assign(expectedState, { + rtl: true, + showTicks: true, + maxValueLabelDisplay: false, + showValueLabels: false, + ticks: [ + { + position: 100, + value: 0, + selected: true, + displayLabel: true, + }, + { + position: 75, + value: 25, + selected: false, + displayLabel: true, + }, + { + position: 50, + value: 50, + selected: false, + displayLabel: true, + }, + { + position: 25, + value: 75, + selected: false, + displayLabel: true, + }, + { + position: 0, + value: 100, + selected: false, + displayLabel: true, + }, + ], + progressDisplayOptions: [ + { + ...expectedState.progressDisplayOptions[0], + left: null, + right: 0, + bottom: null, + top: null, + }, + ], + handleDisplayOptions: [ + { + ...expectedState.handleDisplayOptions[0], + left: 100, + }, + ], + }), + ); + }); + + test(`should automatically hide the min/max/current labels when showTickValues is true`, () => { + slider.patch({ + stepSize: 1, + tickStep: 25, + showTicks: true, + showTickValues: true, + values: [50], + }); + + const expectedState = defaultState(); + + expect(normalizedState$()).toStrictEqual( + assign(expectedState, { + showTicks: true, + stepSize: 1, + values: [50], + sortedValues: [50], + handleDisplayOptions: [ + { + ...expectedState.handleDisplayOptions[0], + left: 50, + }, + ], + sortedHandles: [ + { + ...defaultHandle, + value: 50, + }, + ], + progressDisplayOptions: [ + { + ...expectedState.progressDisplayOptions[0], + width: 50, + }, + ], + ticks: [ + { + position: 0, + value: 0, + selected: true, + displayLabel: true, + }, + { + position: 25, + value: 25, + selected: true, + displayLabel: true, + }, + { + position: 50, + value: 50, + selected: true, + displayLabel: true, + }, + { + position: 75, + value: 75, + selected: false, + displayLabel: true, + }, + { + position: 100, + value: 100, + selected: false, + displayLabel: true, + }, + ], + minValueLabelDisplay: false, + maxValueLabelDisplay: false, + showValueLabels: false, + }), + ); + }); + + test(`should automatically show the min/max/current labels when showTickValues is false`, () => { + slider.patch({ + stepSize: 1, + tickStep: 25, + showTicks: true, + showTickValues: false, + values: [50], + }); + + const expectedState = defaultState(); + + expect(normalizedState$()).toStrictEqual( + assign(expectedState, { + showTicks: true, + stepSize: 1, + values: [50], + sortedValues: [50], + handleDisplayOptions: [ + { + ...expectedState.handleDisplayOptions[0], + left: 50, + }, + ], + sortedHandles: [ + { + ...defaultHandle, + value: 50, + }, + ], + progressDisplayOptions: [ + { + ...expectedState.progressDisplayOptions[0], + width: 50, + }, + ], + ticks: [ + { + position: 0, + value: 0, + selected: true, + displayLabel: false, + }, + { + position: 25, + value: 25, + selected: true, + displayLabel: false, + }, + { + position: 50, + value: 50, + selected: true, + displayLabel: false, + }, + { + position: 75, + value: 75, + selected: false, + displayLabel: false, + }, + { + position: 100, + value: 100, + selected: false, + displayLabel: false, + }, + ], + minValueLabelDisplay: true, + maxValueLabelDisplay: true, + showValueLabels: true, + }), + ); + }); }); describe(`Slider range`, () => { diff --git a/core/src/components/slider/slider.ts b/core/src/components/slider/slider.ts index 9c4b00d74e..b9afc7693e 100644 --- a/core/src/components/slider/slider.ts +++ b/core/src/components/slider/slider.ts @@ -83,6 +83,36 @@ export interface SliderHandle { ariaLabelledBy: string | undefined; } +/** + * Represents a tick in a slider component. + */ +export interface SliderTick { + /** + * CSS classes to be applied on the tick + */ + className?: string | null; + /** + * Visualized optional explanation of the label + */ + legend?: string | null; + /** + * Position of the tick in percent + */ + position: number; + /** + * If `true` the tick has selected style + */ + selected: boolean; + /** + * Value of the tick + */ + value: number; + /** + * If `true` the tick label is displayed + */ + displayLabel: boolean; +} + interface SliderCommonPropsAndState extends WidgetsCommonPropsAndState { /** * Minimum value that can be assigned to the slider @@ -147,6 +177,13 @@ interface SliderCommonPropsAndState extends WidgetsCommonPropsAndState { */ showMinMaxLabels: boolean; + /** + * If `true` the ticks are displayed on the slider + * + * @defaultValue `false` + */ + showTicks: boolean; + /** * It `true` slider display is inversed * @@ -208,6 +245,11 @@ export interface SliderState extends SliderCommonPropsAndState { * Check if the slider is interactive, meaning it is not disabled or readonly */ interactive: boolean; + + /** + * Array of ticks to display on the slider component + */ + ticks: SliderTick[]; } /** @@ -259,6 +301,20 @@ export interface SliderProps extends SliderCommonPropsAndState { * ``` */ onValuesChange: (values: number[]) => void; + + /** + * Unit value between the ticks + * + * @defaultValue `0` + */ + tickStep: number; + + /** + * If `true` the tick values are displayed on the slider + * + * @defaultValue `true` + */ + showTickValues: boolean; } /** @@ -308,6 +364,16 @@ export interface SliderDirectives { * Directive to apply to the handle when combined label display is not active */ handleLabelDisplayDirective: Directive<{index: number}>; + + /** + * Directive to apply to the slider tick + */ + tickDirective: Directive<{item: SliderTick}>; + + /** + * Directive to apply to the slider tick label + */ + tickLabelDirective: Directive<{item: SliderTick}>; } /** @@ -330,6 +396,9 @@ const defaultSliderConfig: SliderProps = { values: [0], showValueLabels: true, showMinMaxLabels: true, + showTicks: false, + showTickValues: true, + tickStep: 0, rtl: false, }; @@ -355,6 +424,9 @@ const configValidator: ConfigValidator = { values: typeArray, showValueLabels: typeBoolean, showMinMaxLabels: typeBoolean, + showTicks: typeBoolean, + showTickValues: typeBoolean, + tickStep: typeNumber, rtl: typeBoolean, className: typeString, }; @@ -409,6 +481,11 @@ const getUpdateDirection = (vertical: boolean, rtl: boolean, keysVertical: boole return 1; }; +/** + * Utility to return percent string + * @param value numberic percent value + * @returns string with % appended to the numberic value + */ const percent = (value: number | null) => (value != null ? `${value}%` : ''); /** @@ -432,6 +509,9 @@ export function createSlider(config?: PropsConfig): SliderWidget { onValuesChange$, showValueLabels$, showMinMaxLabels$, + showTicks$, + showTickValues$, + tickStep$, ...stateProps }, @@ -537,8 +617,11 @@ export function createSlider(config?: PropsConfig): SliderWidget { const sortedValuesPercent$ = computed(() => [...valuesPercent$()].sort((a, b) => a - b)); const minLabelWidth$ = computed(() => (minLabelDomRect$().width / sliderDomRectSize$()) * 100); const maxLabelWidth$ = computed(() => (maxLabelDomRect$().width / sliderDomRectSize$()) * 100); + // to remove adjustedShowValueLabels when the intersection of labels is done + const adjustedShowValueLabels$ = computed(() => showValueLabels$() && (!showTicks$() || !showTickValues$())); const minValueLabelDisplay$ = computed(() => { - if (!showMinMaxLabels$()) { + // to remove showTicks check when the intersection of labels is done + if (!showMinMaxLabels$() || (showTicks$() && showTickValues$())) { return false; } else if (!showValueLabels$()) { return true; @@ -549,7 +632,8 @@ export function createSlider(config?: PropsConfig): SliderWidget { : !valuesPercent$().some((percent) => percent < minLabelWidth + 1); }); const maxValueLabelDisplay$ = computed(() => { - if (!showMinMaxLabels$()) { + // to remove showTicks check when the intersection of labels is done + if (!showMinMaxLabels$() || (showTicks$() && showTickValues$())) { return false; } else if (!showValueLabels$()) { return true; @@ -622,10 +706,62 @@ export function createSlider(config?: PropsConfig): SliderWidget { } }); + const computeTicks$ = computed(() => { + const vertical = vertical$(); + const min = min$(); + const max = max$(); + const rtl = rtl$(); + const showTickValues = showTickValues$(); + + if (!showTicks$()) { + return []; + } + const tickStep = tickStep$() || stepSize$(); + const tickArray = []; + /** + * Utility to decide whether the position is inverted based on the slider orientation + * @param position initial position to check + * @returns postion based on the slider orientation + */ + const positionCompute = (position: number) => { + return !!rtl !== !!vertical ? 100 - position : position; + }; + for (let step = min; step < max; step += tickStep) { + const cleanValue = computeCleanValue(step, min, max, _intStepSize$(), _decimalPrecision$()); + const stepPercent = percentCompute(cleanValue); + tickArray.push({position: positionCompute(stepPercent), selected: false, value: cleanValue, displayLabel: showTickValues}); + } + tickArray.push({position: positionCompute(100), selected: false, value: max, displayLabel: showTickValues}); + return tickArray; + }); + + const ticks$ = computed(() => { + const sortedValues = sortedValues$(); + /** + * Utility to verify whether the value is in the selected range + * @param value value to check + * @returns ``true`` if the value is in the selected range, ``false`` otherwise + */ + const isTickSelected = (value: number) => { + const isMultiHandle = sortedValues.length > 1; + const currentMax = isMultiHandle ? sortedValues[sortedValues.length - 1] : sortedValues[0]; + const currentMin = isMultiHandle ? sortedValues[0] : 0; + return value <= currentMax && value >= currentMin; + }; + + return computeTicks$().map((tick) => { + return { + ...tick, + selected: isTickSelected(tick.value), + }; + }); + }); + // functions const percentCompute = (value: number) => { - const min = min$(); - return ((value - min) * 100) / (max$() - min); + const min = min$(), + max = max$(); + return ((value - min) * 100) / (max - min); }; const getClosestSliderHandle = (clickedPercent: number) => { const values = values$(); @@ -824,9 +960,11 @@ export function createSlider(config?: PropsConfig): SliderWidget { combinedLabelPositionTop$, progressDisplayOptions$, handleDisplayOptions$, - showValueLabels$, + showValueLabels$: adjustedShowValueLabels$, showMinMaxLabels$, + showTicks$, rtl$, + ticks$, ...stateProps, }), patch, @@ -856,6 +994,9 @@ export function createSlider(config?: PropsConfig): SliderWidget { 'au-slider-clickable-area': horizontal$, 'au-slider-clickable-area-vertical': vertical$, }, + styles: { + cursor: computed(() => (showTicks$() ? 'default' : 'pointer')), + }, })), handleEventsDirective, handleDirective: mergeDirectives( @@ -911,6 +1052,33 @@ export function createSlider(config?: PropsConfig): SliderWidget { top: computed(() => percent(handleDisplayOptions$()[labelDisplayContext$().index].top)), }, })), + tickDirective: createAttributesDirective((tickContext$: ReadableSignal<{item: SliderTick}>) => ({ + classNames: { + 'au-slider-tick': true$, + 'au-slider-tick-horizontal': horizontal$, + 'au-slider-tick-vertical': vertical$, + }, + styles: { + left: computed(() => (vertical$() ? null : percent(tickContext$().item.position))), + cursor: computed(() => (disabled$() ? 'disabled' : 'pointer')), + top: computed(() => (vertical$() ? percent(tickContext$().item.position) : null)), + }, + events: { + click: (event: MouseEvent) => { + adjustCoordinate(vertical$() ? event.clientY : event.clientX); + }, + }, + })), + tickLabelDirective: createAttributesDirective((tickContext$: ReadableSignal<{item: SliderTick}>) => ({ + classNames: { + 'au-slider-tick-label': true$, + 'au-slider-tick-label-vertical': vertical$, + }, + styles: { + left: computed(() => (vertical$() ? null : percent(tickContext$().item.position))), + top: computed(() => (vertical$() ? percent(tickContext$().item.position) : null)), + }, + })), }, }; diff --git a/demo/src/routes/docs/[framework]/components/slider/examples/+page.svelte b/demo/src/routes/docs/[framework]/components/slider/examples/+page.svelte index f5d11f7f87..4a40b64df6 100644 --- a/demo/src/routes/docs/[framework]/components/slider/examples/+page.svelte +++ b/demo/src/routes/docs/[framework]/components/slider/examples/+page.svelte @@ -6,6 +6,7 @@ import customSample from '@agnos-ui/samples/bootstrap/slider/custom'; import fullcustomSample from '@agnos-ui/samples/bootstrap/slider/fullCustom'; import customAccessibility from '@agnos-ui/samples/bootstrap/slider/accessibility'; + import withTicks from '@agnos-ui/samples/bootstrap/slider/ticks'; import Sample from '$lib/layout/Sample.svelte'; import Section from '$lib/layout/Section.svelte'; @@ -16,6 +17,9 @@
+
+ +
diff --git a/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-slider-accessibility.html b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-slider-accessibility.html index 2824aabae5..0ee8649fc6 100644 --- a/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-slider-accessibility.html +++ b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-slider-accessibility.html @@ -17,6 +17,7 @@ />