diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example02.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example02.ts index 97ba6ebbd..58af6df6b 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example02.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example02.ts @@ -9,6 +9,7 @@ import { GridOption, Grouping, GroupTotalFormatters, + SliderOption, SortComparers, SortDirectionNumber, } from '@slickgrid-universal/common'; @@ -76,7 +77,11 @@ export class Example2 { id: 'duration', name: 'Duration', field: 'duration', minWidth: 50, width: 60, filterable: true, - filter: { model: Filters.slider, operator: '>=' }, + filter: { + model: Filters.slider, + operator: '>=', + filterOptions: { hideSliderNumber: true, enableSliderTrackColoring: true, sliderTrackFilledColor: '#9ac49c' } as SliderOption + }, sortable: true, type: FieldType.number, groupTotalsFormatter: GroupTotalFormatters.sumTotals, diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts index 8f9c41ffe..94e215e92 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts @@ -107,7 +107,9 @@ export class Example7 { }, { id: 'percentComplete', nameKey: 'PERCENT_COMPLETE', field: 'percentComplete', type: 'number', - filterable: true, sortable: true, editor: { model: Editors.slider, minValue: 0, maxValue: 100, }, + filterable: true, sortable: true, + filter: { model: Filters.compoundSlider, minValue: 0, maxValue: 100, operator: '>=' }, + editor: { model: Editors.slider, minValue: 0, maxValue: 100, }, }, { id: 'start', nameKey: 'START', field: 'start', formatter: Formatters.dateIso, diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example14.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example14.ts index 528d20b48..dd04add9e 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example14.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example14.ts @@ -179,6 +179,7 @@ export class Example14 { filter: { model: Filters.sliderRange, operator: '>=', + // searchTerms: [15, 78], filterOptions: { enableSliderTrackColoring: true, hideSliderNumbers: false, diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts index 8d4f2b106..be697964f 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts @@ -143,7 +143,7 @@ export class Example16 { type: FieldType.number, }, { - id: 'percentComplete', name: '% Complete', field: 'percentComplete', type: FieldType.number, + id: 'percentComplete', name: '% Complete', field: 'percentComplete', type: FieldType.number, minWidth: 130, editor: { model: Editors.slider, minValue: 0, @@ -153,7 +153,7 @@ export class Example16 { exportWithFormatter: false, formatter: Formatters.percentCompleteBar, sortable: true, filterable: true, - filter: { model: Filters.slider, operator: '>=' }, + filter: { model: Filters.sliderRange, operator: '>=' }, customTooltip: { useRegularTooltip: true, position: 'center' }, }, { diff --git a/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts b/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts index 19697a90d..7003f822b 100644 --- a/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts +++ b/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts @@ -69,16 +69,17 @@ describe('CompoundSliderFilter', () => { expect(spyGetHeaderRow).toHaveBeenCalled(); expect(filterCount).toBe(1); + expect(filter.currentValue).toBe(0); }); it('should have an aria-label when creating the filter', () => { filter.init(filterArguments); const filterInputElm = divContainer.querySelector('.input-group.search-filter.filter-duration input') as HTMLInputElement; - expect(filterInputElm.getAttribute('aria-label')).toBe('Duration Search Filter'); + expect(filterInputElm.ariaLabel).toBe('Duration Search Filter'); }); - it('should call "setValues" with "operator" set in the filter arguments and expect that value to be in the callback when triggered', () => { + it('should call "setValues" with "operator" set in the filter arguments and expect that value to be converted to number and in the callback when triggered', () => { const callbackSpy = jest.spyOn(filterArguments, 'callback'); const rowMouseEnterSpy = jest.spyOn(gridStub.onHeaderRowMouseEnter, 'notify'); const filterArgs = { ...filterArguments, operator: '>', grid: gridStub } as FilterArguments; @@ -90,7 +91,7 @@ describe('CompoundSliderFilter', () => { jest.runAllTimers(); // fast-forward timer - expect(callbackSpy).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['2'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: [2], shouldTriggerQuery: true }); expect(rowMouseEnterSpy).toHaveBeenCalledWith({ column: mockColumn, grid: gridStub }, expect.anything()); }); @@ -105,7 +106,7 @@ describe('CompoundSliderFilter', () => { const filterFilledElms = divContainer.querySelectorAll('.slider-container.search-filter.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); - expect(callbackSpy).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: ['3'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: [3], shouldTriggerQuery: true }); }); it('should trigger an operator change event and expect the callback to be called with the searchTerms and operator defined', () => { @@ -118,10 +119,10 @@ describe('CompoundSliderFilter', () => { filterSelectElm.value = '<='; filterSelectElm.dispatchEvent(new CustomEvent('change')); - expect(callbackSpy).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: ['9'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: [9], shouldTriggerQuery: true }); }); - it('should be able to call "setValues" with a value and an extra operator and expect it to be set as new operator', () => { + it('should be able to call "setValues" with a value, converted as a number, and an extra operator and expect it to be set as new operator', () => { const callbackSpy = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); @@ -130,7 +131,7 @@ describe('CompoundSliderFilter', () => { const filterSelectElm = divContainer.querySelector('.search-filter.filter-duration select') as HTMLInputElement; filterSelectElm.dispatchEvent(new CustomEvent('change')); - expect(callbackSpy).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>=', searchTerms: ['9'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>=', searchTerms: [9], shouldTriggerQuery: true }); }); it('should be able to call "setValues" and set empty values and the input to not have the "filled" css class', () => { @@ -186,7 +187,7 @@ describe('CompoundSliderFilter', () => { it('should create the input filter with min/max slider values being set by filter "sliderStartValue" and "sliderEndValue" through the filter params', () => { mockColumn.filter = { - params: { + filterOptions: { sliderStartValue: 4, sliderEndValue: 69, } @@ -202,7 +203,7 @@ describe('CompoundSliderFilter', () => { it('should create the input filter with default search terms range but without showing side numbers when "hideSliderNumber" is set in params', () => { filterArguments.searchTerms = [3]; - mockColumn.filter!.params = { hideSliderNumber: true }; + mockColumn.filter!.filterOptions = { hideSliderNumber: true }; filter.init(filterArguments); @@ -220,7 +221,7 @@ describe('CompoundSliderFilter', () => { filter.clear(); expect(filter.getValues()).toBe(0); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: true }); }); it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { @@ -231,14 +232,14 @@ describe('CompoundSliderFilter', () => { filter.clear(false); expect(filter.getValues()).toBe(0); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); }); it('should trigger a callback with the clear filter set when calling the "clear" method and expect min slider values being with values of "sliderStartValue" when defined through the filter params', () => { const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [3], grid: gridStub, } as FilterArguments; const callbackSpy = jest.spyOn(filterArguments, 'callback'); mockColumn.filter = { - params: { + filterOptions: { sliderStartValue: 4, sliderEndValue: 69, } @@ -248,7 +249,32 @@ describe('CompoundSliderFilter', () => { filter.clear(false); expect(filter.getValues()).toEqual(4); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); + }); + + it('should enableSliderTrackColoring and trigger a change event and expect slider track to have background color', () => { + mockColumn.filter = { filterOptions: { enableSliderTrackColoring: true } }; + filter.init(filterArguments); + filter.setValues(['80']); + const filterElms = divContainer.querySelectorAll('.search-filter.slider-container.filter-duration input'); + filterElms[0].dispatchEvent(new CustomEvent('change')); + + expect(filter.sliderOptions?.sliderTrackBackground).toBe('linear-gradient(to right, #eee 0%, var(--slick-slider-filter-thumb-color, #86bff8) 0%, var(--slick-slider-filter-thumb-color, #86bff8) 80%, #eee 80%)'); + }); + + it('should click on the slider track and expect handle to move to the new position', () => { + filter.init(filterArguments); + const sliderInputs = divContainer.querySelectorAll('.slider-filter-input'); + const sliderTrackElm = divContainer.querySelector('.slider-track') as HTMLDivElement; + + const sliderRightChangeSpy = jest.spyOn(sliderInputs[0], 'dispatchEvent'); + + const clickEvent = new Event('click'); + Object.defineProperty(clickEvent, 'offsetX', { writable: true, configurable: true, value: 56 }); + Object.defineProperty(sliderTrackElm, 'offsetWidth', { writable: true, configurable: true, value: 75 }); + sliderTrackElm.dispatchEvent(clickEvent); + + expect(sliderRightChangeSpy).toHaveBeenCalled(); }); it('should create the input filter with all available operators in a select dropdown options as a prepend element', () => { diff --git a/packages/common/src/filters/__tests__/sliderFilter.spec.ts b/packages/common/src/filters/__tests__/singleSliderFilter.spec.ts similarity index 73% rename from packages/common/src/filters/__tests__/sliderFilter.spec.ts rename to packages/common/src/filters/__tests__/singleSliderFilter.spec.ts index c2ad7aa0e..bb6faeaec 100644 --- a/packages/common/src/filters/__tests__/sliderFilter.spec.ts +++ b/packages/common/src/filters/__tests__/singleSliderFilter.spec.ts @@ -1,6 +1,7 @@ import { Filters } from '../filters.index'; import { Column, FilterArguments, GridOption, SlickGrid, SlickNamespace } from '../../interfaces/index'; -import { SliderFilter } from '../sliderFilter'; +import { SingleSliderFilter } from '../singleSliderFilter'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; const containerId = 'demo-container'; declare const Slick: SlickNamespace; @@ -23,14 +24,16 @@ const gridStub = { onHeaderRowMouseEnter: new Slick.Event(), } as unknown as SlickGrid; -describe('SliderFilter', () => { +describe('SingleSliderFilter', () => { + let translateService: TranslateServiceStub; let divContainer: HTMLDivElement; - let filter: SliderFilter; + let filter: SingleSliderFilter; let filterArgs: FilterArguments; let spyGetHeaderRow; let mockColumn: Column; beforeEach(() => { + translateService = new TranslateServiceStub(); divContainer = document.createElement('div'); divContainer.innerHTML = template; document.body.appendChild(divContainer); @@ -44,7 +47,7 @@ describe('SliderFilter', () => { filterContainerElm: gridStub.getHeaderRowColumn(mockColumn.id) }; - filter = new SliderFilter(); + filter = new SingleSliderFilter(translateService); }); afterEach(() => { @@ -61,16 +64,17 @@ describe('SliderFilter', () => { expect(spyGetHeaderRow).toHaveBeenCalled(); expect(filterCount).toBe(1); + expect(filter.currentValue).toBe(0); }); it('should have an aria-label when creating the filter', () => { filter.init(filterArgs); const filterInputElm = divContainer.querySelector('.search-filter.slider-container.filter-duration input') as HTMLInputElement; - expect(filterInputElm.getAttribute('aria-label')).toBe('Duration Search Filter'); + expect(filterInputElm.ariaLabel).toBe('Duration Search Filter'); }); - it('should call "setValues" and expect that value to be in the callback when triggered', () => { + it('should call "setValues" and expect that value, converted as a number, to be in the callback when triggered', () => { const callbackSpy = jest.spyOn(filterArgs, 'callback'); const rowMouseEnterSpy = jest.spyOn(gridStub.onHeaderRowMouseEnter, 'notify'); @@ -81,11 +85,11 @@ describe('SliderFilter', () => { jest.runAllTimers(); // fast-forward timer - expect(callbackSpy).toHaveBeenLastCalledWith(new CustomEvent('change'), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['2'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(new CustomEvent('change'), { columnDef: mockColumn, operator: 'EQ', searchTerms: [2], shouldTriggerQuery: true }); expect(rowMouseEnterSpy).toHaveBeenCalledWith({ column: mockColumn, grid: gridStub }, expect.anything()); }); - it('should call "setValues" and expect that value, converted as a string, to be in the callback when triggered', () => { + it('should call "setValues" and expect that value, converted as a number, to be in the callback when triggered', () => { const callbackSpy = jest.spyOn(filterArgs, 'callback'); filter.init(filterArgs); @@ -98,7 +102,7 @@ describe('SliderFilter', () => { const filterFilledElms = divContainer.querySelectorAll('.search-filter.slider-container.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); - expect(callbackSpy).toHaveBeenLastCalledWith(new CustomEvent('change'), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['13'], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(new CustomEvent('change'), { columnDef: mockColumn, operator: 'EQ', searchTerms: [3], shouldTriggerQuery: true }); }); it('should be able to call "setValues" and set empty values and the input to not have the "filled" css class', () => { @@ -154,7 +158,7 @@ describe('SliderFilter', () => { it('should create the input filter with min/max slider values being set by filter "sliderStartValue" and "sliderEndValue" through the filter params', () => { mockColumn.filter = { - params: { + filterOptions: { sliderStartValue: 4, sliderEndValue: 69, } @@ -170,7 +174,7 @@ describe('SliderFilter', () => { it('should create the input filter with default search terms range but without showing side numbers when "hideSliderNumber" is set in params', () => { filterArgs.searchTerms = [3]; - mockColumn.filter!.params = { hideSliderNumber: true }; + mockColumn.filter!.filterOptions = { hideSliderNumber: true }; filter.init(filterArgs); @@ -188,7 +192,7 @@ describe('SliderFilter', () => { filter.clear(); expect(filter.getValues()).toBe(0); - expect(callbackSpy).toHaveBeenLastCalledWith(new Event('change'), { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: true }); }); it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { @@ -199,13 +203,13 @@ describe('SliderFilter', () => { filter.clear(false); expect(filter.getValues()).toBe(0); - expect(callbackSpy).toHaveBeenLastCalledWith(new Event('change'), { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); }); it('should trigger a callback with the clear filter set when calling the "clear" method and expect min slider values being with values of "sliderStartValue" when defined through the filter params', () => { const callbackSpy = jest.spyOn(filterArgs, 'callback'); mockColumn.filter = { - params: { + filterOptions: { sliderStartValue: 4, sliderEndValue: 69, } @@ -215,6 +219,31 @@ describe('SliderFilter', () => { filter.clear(false); expect(filter.getValues()).toEqual(4); - expect(callbackSpy).toHaveBeenLastCalledWith(new Event('change'), { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); + }); + + it('should enableSliderTrackColoring and trigger a change event and expect slider track to have background color', () => { + mockColumn.filter = { filterOptions: { enableSliderTrackColoring: true } }; + filter.init(filterArgs); + filter.setValues(['80']); + const filterElms = divContainer.querySelectorAll('.search-filter.slider-container.filter-duration input'); + filterElms[0].dispatchEvent(new CustomEvent('change')); + + expect(filter.sliderOptions?.sliderTrackBackground).toBe('linear-gradient(to right, #eee 0%, var(--slick-slider-filter-thumb-color, #86bff8) 0%, var(--slick-slider-filter-thumb-color, #86bff8) 80%, #eee 80%)'); + }); + + it('should click on the slider track and expect handle to move to the new position', () => { + filter.init(filterArgs); + const sliderInputs = divContainer.querySelectorAll('.slider-filter-input'); + const sliderTrackElm = divContainer.querySelector('.slider-track') as HTMLDivElement; + + const sliderRightChangeSpy = jest.spyOn(sliderInputs[0], 'dispatchEvent'); + + const clickEvent = new Event('click'); + Object.defineProperty(clickEvent, 'offsetX', { writable: true, configurable: true, value: 56 }); + Object.defineProperty(sliderTrackElm, 'offsetWidth', { writable: true, configurable: true, value: 75 }); + sliderTrackElm.dispatchEvent(clickEvent); + + expect(sliderRightChangeSpy).toHaveBeenCalled(); }); }); diff --git a/packages/common/src/filters/__tests__/sliderRangeFilter.spec.ts b/packages/common/src/filters/__tests__/sliderRangeFilter.spec.ts index fe92dc65d..6b11801a5 100644 --- a/packages/common/src/filters/__tests__/sliderRangeFilter.spec.ts +++ b/packages/common/src/filters/__tests__/sliderRangeFilter.spec.ts @@ -1,6 +1,7 @@ import { Filters } from '../filters.index'; import { Column, FilterArguments, GridOption, SlickGrid, SlickNamespace } from '../../interfaces/index'; import { SliderRangeFilter } from '../sliderRangeFilter'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; const containerId = 'demo-container'; declare const Slick: SlickNamespace; @@ -24,6 +25,7 @@ const gridStub = { } as unknown as SlickGrid; describe('SliderRangeFilter', () => { + let translateService: TranslateServiceStub; let consoleSpy: any; let divContainer: HTMLDivElement; let filter: SliderRangeFilter; @@ -32,6 +34,7 @@ describe('SliderRangeFilter', () => { let mockColumn: Column; beforeEach(() => { + translateService = new TranslateServiceStub(); consoleSpy = jest.spyOn(global.console, 'warn').mockReturnValue(); divContainer = document.createElement('div'); divContainer.innerHTML = template; @@ -46,7 +49,7 @@ describe('SliderRangeFilter', () => { filterContainerElm: gridStub.getHeaderRowColumn(mockColumn.id) }; - filter = new SliderRangeFilter(); + filter = new SliderRangeFilter(translateService); }); afterEach(() => { @@ -68,7 +71,7 @@ describe('SliderRangeFilter', () => { it('should be able to retrieve default slider options through the Getter', () => { filter.init(filterArguments); - expect(filter.sliderRangeOptions).toEqual({ + expect(filter.sliderOptions).toEqual({ maxValue: 100, minValue: 0, step: 1, @@ -83,7 +86,7 @@ describe('SliderRangeFilter', () => { }; filter.init(filterArguments); - expect(filter.sliderRangeOptions).toEqual({ + expect(filter.sliderOptions).toEqual({ maxValue: 69, minValue: 4, step: 5, @@ -271,7 +274,7 @@ describe('SliderRangeFilter', () => { filter.clear(); expect(filter.currentValues).toEqual([0, 100]); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: true }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: true }); }); it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { @@ -282,7 +285,7 @@ describe('SliderRangeFilter', () => { filter.clear(false); expect(filter.currentValues).toEqual([0, 100]); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); }); it('should trigger a callback with the clear filter set when calling the "clear" method and expect min/max slider values being with values of "sliderStartValue" and "sliderEndValue" when defined through the filterOptions', () => { @@ -298,7 +301,7 @@ describe('SliderRangeFilter', () => { filter.clear(false); expect(filter.currentValues).toEqual([4, 69]); - expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + expect(callbackSpy).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, searchTerms: [], shouldTriggerQuery: false }); }); it('should enableSliderTrackColoring and trigger a change event and expect slider track to have background color', () => { @@ -310,7 +313,7 @@ describe('SliderRangeFilter', () => { const sliderTrackElm = divContainer.querySelector('.slider-track') as HTMLDivElement; // expect(sliderTrackElm.style.background).toBe('linear-gradient(to right, #eee 2%, var(--slick-slider-filter-thumb-color, #86bff8) 2%, var(--slick-slider-filter-thumb-color, #86bff8) 80%, #eee 80%)'); - expect(filter.sliderRangeOptions?.sliderTrackBackground).toBe('linear-gradient(to right, #eee 2%, var(--slick-slider-filter-thumb-color, #86bff8) 2%, var(--slick-slider-filter-thumb-color, #86bff8) 80%, #eee 80%)'); + expect(filter.sliderOptions?.sliderTrackBackground).toBe('linear-gradient(to right, #eee 2%, var(--slick-slider-filter-thumb-color, #86bff8) 2%, var(--slick-slider-filter-thumb-color, #86bff8) 80%, #eee 80%)'); }); it('should click on the slider track and expect left handle to move to the new position when calculated percent is below 50%', () => { diff --git a/packages/common/src/filters/compoundSliderFilter.ts b/packages/common/src/filters/compoundSliderFilter.ts index f43201e91..7811ae878 100644 --- a/packages/common/src/filters/compoundSliderFilter.ts +++ b/packages/common/src/filters/compoundSliderFilter.ts @@ -1,299 +1,12 @@ -import { hasData, toSentenceCase } from '@slickgrid-universal/utils'; - -import { - Column, - ColumnFilter, - DOMEvent, - Filter, - FilterArguments, - FilterCallback, - GridOption, - OperatorDetail, - SlickGrid, - SlickNamespace, -} from '../interfaces/index'; -import { Constants } from '../constants'; -import { OperatorString, OperatorType, SearchTerm } from '../enums/index'; -import { buildSelectOperator, compoundOperatorNumeric } from './filterUtilities'; -import { createDomElement, emptyElement } from '../services/domUtilities'; -import { mapOperatorToShorthandDesignation, } from '../services/utilities'; -import { BindingEventService } from '../services/bindingEvent.service'; -import { TranslaterService } from '../services/translater.service'; - -declare const Slick: SlickNamespace; - -export class CompoundSliderFilter implements Filter { - protected _bindEventService: BindingEventService; - protected _clearFilterTriggered = false; - protected _currentValue?: number; - protected _shouldTriggerQuery = true; - protected _elementRangeInputId = ''; - protected _elementRangeOutputId = ''; - protected _operator?: OperatorType | OperatorString; - protected containerInputGroupElm?: HTMLDivElement; - protected divContainerFilterElm?: HTMLDivElement; - protected filterElm!: HTMLDivElement; - protected filterInputElm!: HTMLInputElement; - protected filterNumberElm?: HTMLSpanElement; - protected selectOperatorElm!: HTMLSelectElement; - grid!: SlickGrid; - searchTerms: SearchTerm[] = []; - columnDef!: Column; - callback!: FilterCallback; - filterContainerElm!: HTMLDivElement; - - constructor(protected readonly translaterService: TranslaterService) { - this._bindEventService = new BindingEventService(); - } - - /** Getter for the Column Filter */ - get columnFilter(): ColumnFilter { - return this.columnDef?.filter ?? {}; - } - - /** Getter to know what would be the default operator when none is specified */ - get defaultOperator(): OperatorType | OperatorString { - return OperatorType.empty; - } - - /** Getter for the Filter Generic Params */ - protected get filterParams(): any { - return this.columnDef?.filter?.params ?? {}; - } - - /** Getter for the Grid Options pulled through the Grid Object */ - protected get gridOptions(): GridOption { - return this.grid?.getOptions?.() ?? {}; - } - - /** Getter for the Filter Operator */ - get operator(): OperatorType | OperatorString { - return this._operator || this.columnFilter.operator || this.defaultOperator; - } - - /** Setter for the Filter Operator */ - set operator(operator: OperatorType | OperatorString) { - this._operator = operator; - } +import { TranslaterService } from '../services'; +import { SliderFilter } from './sliderFilter'; +export class CompoundSliderFilter extends SliderFilter { /** * Initialize the Filter */ - init(args: FilterArguments) { - if (!args) { - throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); - } - this.grid = args.grid; - this.callback = args.callback; - this.columnDef = args.columnDef; - this.operator = args.operator || ''; - this.searchTerms = args?.searchTerms ?? []; - this.filterContainerElm = args.filterContainerElm; - - // define the input & slider number IDs - this._elementRangeInputId = `rangeInput_${this.columnDef.field}`; - this._elementRangeOutputId = `rangeOutput_${this.columnDef.field}`; - - // filter input can only have 1 search term, so we will use the 1st array index if it exist - const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; - - // step 1, create the DOM Element of the filter which contain the compound Operator+Input - // and initialize it if searchTerm is filled - this.filterElm = this.createDomFilterElement(searchTerm); - - // step 2, subscribe to the change event and run the callback when that happens - // also add/remove "filled" class for styling purposes - this._bindEventService.bind(this.filterInputElm, ['change', 'mouseup', 'touchend'], this.handleFilterChange.bind(this) as EventListener); - this._bindEventService.bind(this.selectOperatorElm, ['change', 'mouseup', 'touchend'], this.handleFilterChange.bind(this) as EventListener); - - // if user chose to display the slider number on the right side, then update it every time it changes - // we need to use both "input" and "change" event to support cross-browser - this._bindEventService.bind(this.filterInputElm, ['input', 'change'], this.handleInputChange.bind(this)); - } - - /** - * Clear the filter value - */ - clear(shouldTriggerQuery = true) { - if (this.filterElm && this.selectOperatorElm) { - this._clearFilterTriggered = true; - this._shouldTriggerQuery = shouldTriggerQuery; - this.searchTerms = []; - const clearedValue = this.filterParams?.sliderStartValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE; - this._currentValue = +clearedValue; - this.selectOperatorElm.selectedIndex = 0; - this.filterInputElm.value = clearedValue; - if (this.filterNumberElm) { - this.filterNumberElm.textContent = clearedValue; - } - this.filterElm.classList.remove('filled'); - this.divContainerFilterElm?.classList.remove('filled'); - this.handleFilterChange(undefined); - } - } - - /** - * destroy the filter - */ - destroy() { - this._bindEventService.unbindAll(); - this.selectOperatorElm?.remove?.(); - emptyElement(this.filterElm); - this.filterElm?.remove?.(); - } - - /** - * Get selected value retrieved from the slider element - * @params selected items - */ - getValues(): number | undefined { - return this._currentValue; - } - - /** Set value(s) on the DOM element */ - setValues(values: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString) { - const newValue = Array.isArray(values) ? values[0] : values; - this._currentValue = hasData(newValue) ? +newValue : undefined; - this.filterInputElm.value = `${newValue ?? ''}`; - if (this.filterNumberElm) { - this.filterNumberElm.textContent = `${this._currentValue ?? ''}`; - } - - if (this.getValues() !== undefined) { - this.filterElm.classList.add('filled'); - this.divContainerFilterElm?.classList.add('filled'); - } else { - this.filterElm.classList.remove('filled'); - this.divContainerFilterElm?.classList.remove('filled'); - } - - // set the operator when defined - this.operator = operator || this.defaultOperator; - if (operator && this.selectOperatorElm) { - const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); - this.selectOperatorElm.value = operatorShorthand; - } - } - - // - // protected functions - // ------------------ - - /** - * Create the Filter DOM element - * @param searchTerm optional preset search terms - */ - protected createDomFilterElement(searchTerm?: SearchTerm): HTMLDivElement { - const columnId = this.columnDef?.id ?? ''; - const minValue = this.columnFilter?.minValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE; - const maxValue = this.columnFilter?.maxValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE; - const defaultValue = this.filterParams?.sliderStartValue ?? minValue; - const step = this.columnFilter?.valueStep ?? Constants.SLIDER_DEFAULT_STEP; - const startValue = +(this.filterParams?.sliderStartValue ?? minValue); - emptyElement(this.filterContainerElm); - - // create the DOM element & add an ID and filter class - let searchTermInput = (searchTerm || '0') as string; - if (+searchTermInput < minValue) { - searchTermInput = `${minValue}`; - } - if (+searchTermInput < startValue) { - searchTermInput = `${startValue}`; - } - this._currentValue = +searchTermInput; - - // create the DOM Select dropdown for the Operator - this.selectOperatorElm = buildSelectOperator(this.getOperatorOptionValues(), this.gridOptions); - - const spanPrependElm = createDomElement('span', { className: 'input-group-addon input-group-prepend operator' }); - spanPrependElm.appendChild(this.selectOperatorElm); - - // create the DOM element - this.filterInputElm = createDomElement('input', { - type: 'range', name: this._elementRangeInputId, - className: `form-control slider-filter-input range compound-slider ${this._elementRangeInputId}`, - defaultValue, value: searchTermInput, title: searchTermInput, - min: `${minValue}`, max: `${maxValue}`, step: `${step}`, - }); - this.filterInputElm.setAttribute('aria-label', this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`); - - this.divContainerFilterElm = createDomElement('div', { className: `form-group search-filter slider-single slider-container filter-${columnId}` }); - this.containerInputGroupElm = createDomElement('div', { className: `input-group search-filter filter-${columnId}` }); - this.containerInputGroupElm.appendChild(spanPrependElm); - this.containerInputGroupElm.appendChild(this.filterInputElm); - this.divContainerFilterElm.appendChild(this.containerInputGroupElm); - - if (!this.filterParams.hideSliderNumber) { - this.containerInputGroupElm.classList.add('input-group'); - this.filterInputElm.value = searchTermInput; - - const divGroupAppendElm = createDomElement('div', { className: 'input-group-addon input-group-append slider-value' }); - this.filterNumberElm = createDomElement('span', { - className: `input-group-text ${this._elementRangeOutputId}`, - textContent: searchTermInput - }); - divGroupAppendElm.appendChild(this.filterNumberElm); - this.containerInputGroupElm.appendChild(divGroupAppendElm); - } - - this.divContainerFilterElm.dataset.columnid = `${columnId}`; - - if (this.operator) { - const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); - this.selectOperatorElm.value = operatorShorthand; - } - - // if there's a search term, we will add the "filled" class for styling purposes - if (searchTerm) { - this.divContainerFilterElm.classList.add('filled'); - } - - // append the new DOM element to the header row - this.filterContainerElm.appendChild(this.divContainerFilterElm); - - return this.divContainerFilterElm; - } - - /** Get the available operator option values to populate the operator select dropdown list */ - protected getOperatorOptionValues(): OperatorDetail[] { - if (this.columnFilter?.compoundOperatorList) { - return this.columnFilter.compoundOperatorList; - } - return compoundOperatorNumeric(this.gridOptions, this.translaterService); - } - - protected handleInputChange(event: Event) { - const value = (event?.target as HTMLInputElement).value; - if (value !== undefined && value !== null) { - if (!this.filterParams.hideSliderNumber && this.filterNumberElm?.textContent) { - this.filterNumberElm.textContent = value; - } - this.filterInputElm.title = value; - } - } - - protected handleFilterChange(e?: DOMEvent) { - const value = this.filterInputElm.value; - this._currentValue = +value; - - if (this._clearFilterTriggered) { - this.filterElm.classList.remove('filled'); - this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); - } else { - const selectedOperator = this.selectOperatorElm.value as OperatorString; - value === '' ? this.filterElm.classList.remove('filled') : this.filterElm.classList.add('filled'); - this.callback(e, { columnDef: this.columnDef, searchTerms: [value || '0'], operator: selectedOperator || '', shouldTriggerQuery: this._shouldTriggerQuery }); - } - - // reset both flags for next use - this._clearFilterTriggered = false; - this._shouldTriggerQuery = true; - - // trigger mouse enter event on the filter for optionally hooked SlickCustomTooltip - // the minimum requirements for tooltip to work are the columnDef and targetElement - setTimeout(() => this.grid.onHeaderRowMouseEnter.notify( - { column: this.columnDef, grid: this.grid }, - { ...new Slick.EventData(), target: this.filterContainerElm } - )); + constructor(protected readonly translaterService: TranslaterService) { + super(translaterService); + super.sliderType = 'compound'; } } diff --git a/packages/common/src/filters/filters.index.ts b/packages/common/src/filters/filters.index.ts index 1be909ffc..6a6af96d8 100644 --- a/packages/common/src/filters/filters.index.ts +++ b/packages/common/src/filters/filters.index.ts @@ -12,7 +12,7 @@ import { MultipleSelectFilter } from './multipleSelectFilter'; import { NativeSelectFilter } from './nativeSelectFilter'; import { DateRangeFilter } from './dateRangeFilter'; import { SingleSelectFilter } from './singleSelectFilter'; -import { SliderFilter } from './sliderFilter'; +import { SingleSliderFilter } from './singleSliderFilter'; import { SliderRangeFilter } from './sliderRangeFilter'; export const Filters = { @@ -68,7 +68,7 @@ export const Filters = { singleSelect: SingleSelectFilter, /** Slider Filter (single value) */ - slider: SliderFilter, + slider: SingleSliderFilter, /** Slider Range Filter (dual values, lowest/highest filter range) */ sliderRange: SliderRangeFilter, diff --git a/packages/common/src/filters/index.ts b/packages/common/src/filters/index.ts index 4cc6daedf..0f83b8348 100644 --- a/packages/common/src/filters/index.ts +++ b/packages/common/src/filters/index.ts @@ -15,4 +15,6 @@ export * from './multipleSelectFilter'; export * from './nativeSelectFilter'; export * from './selectFilter'; export * from './singleSelectFilter'; +export * from './singleSliderFilter'; +export * from './sliderRangeFilter'; export * from './sliderFilter'; diff --git a/packages/common/src/filters/singleSliderFilter.ts b/packages/common/src/filters/singleSliderFilter.ts new file mode 100644 index 000000000..4bf68114a --- /dev/null +++ b/packages/common/src/filters/singleSliderFilter.ts @@ -0,0 +1,12 @@ +import { TranslaterService } from '../services'; +import { SliderFilter } from './sliderFilter'; + +export class SingleSliderFilter extends SliderFilter { + /** + * Initialize the Filter + */ + constructor(protected readonly translaterService: TranslaterService) { + super(translaterService); + super.sliderType = 'single'; + } +} diff --git a/packages/common/src/filters/sliderFilter.ts b/packages/common/src/filters/sliderFilter.ts index d5051f9a0..81e04f50b 100644 --- a/packages/common/src/filters/sliderFilter.ts +++ b/packages/common/src/filters/sliderFilter.ts @@ -1,73 +1,123 @@ import { hasData, toSentenceCase } from '@slickgrid-universal/utils'; import { Constants } from '../constants'; -import { OperatorType, OperatorString, SearchTerm, } from '../enums/index'; +import { OperatorString, OperatorType, SearchTerm, } from '../enums/index'; import { Column, ColumnFilter, - DOMEvent, Filter, FilterArguments, FilterCallback, + GridOption, + OperatorDetail, SlickGrid, SlickNamespace, + SliderRangeOption, } from '../interfaces/index'; import { BindingEventService } from '../services/bindingEvent.service'; -import { createDomElement, emptyElement, } from '../services/domUtilities'; +import { createDomElement, emptyElement } from '../services/domUtilities'; +import { TranslaterService } from '../services/translater.service'; +import { mapOperatorToShorthandDesignation } from '../services/utilities'; +import { buildSelectOperator, compoundOperatorNumeric } from './filterUtilities'; + +interface CurrentSliderOption { + minValue: number; + maxValue: number; + step: number; + sliderTrackBackground?: string; +} +type SliderType = 'single' | 'double' | 'compound'; declare const Slick: SlickNamespace; +const GAP_BETWEEN_SLIDER_HANDLES = 0; +const Z_INDEX_MIN_GAP = 20; // gap in Px before we change z-index so that lowest/highest handle doesn't block each other +/** A Slider Range Filter written in pure JS, this is only meant to be used as a range filter (with 2 handles lowest & highest values) */ export class SliderFilter implements Filter { protected _bindEventService: BindingEventService; protected _clearFilterTriggered = false; protected _currentValue?: number; + protected _currentValues?: number[]; protected _shouldTriggerQuery = true; - protected _elementRangeInputId = ''; - protected _elementRangeOutputId = ''; - protected divContainerFilterElm?: HTMLDivElement; - protected filterElm!: HTMLDivElement; - protected filterInputElm!: HTMLInputElement; - protected filterNumberElm?: HTMLSpanElement; + protected _sliderOptions!: CurrentSliderOption; + protected _operator?: OperatorType | OperatorString; + protected _filterElm!: HTMLDivElement; + protected _argFilterContainerElm!: HTMLDivElement; + protected _divContainerFilterElm!: HTMLDivElement; + protected _filterContainerElm!: HTMLDivElement; + protected _leftSliderNumberElm?: HTMLSpanElement; + protected _rightSliderNumberElm?: HTMLSpanElement; + protected _selectOperatorElm?: HTMLSelectElement; + protected _sliderRangeContainElm!: HTMLDivElement; + protected _sliderTrackElm!: HTMLDivElement; + protected _sliderLeftElm?: HTMLInputElement; + protected _sliderRightElm?: HTMLInputElement; + sliderType: SliderType = 'double'; grid!: SlickGrid; searchTerms: SearchTerm[] = []; columnDef!: Column; callback!: FilterCallback; - filterContainerElm!: HTMLDivElement; - constructor() { + constructor(protected readonly translaterService: TranslaterService) { this._bindEventService = new BindingEventService(); } + /** @deprecated Getter for the Filter Generic Params */ + protected get filterParams(): any { + return this.columnDef?.filter?.params ?? {}; + } + + /** Getter for the Filter Options */ + get filterOptions(): SliderRangeOption | undefined { + return this.columnFilter.filterOptions; + } + /** Getter for the Column Filter */ get columnFilter(): ColumnFilter { return this.columnDef?.filter ?? {}; } + /** Getter for the Current Slider Value */ + get currentValue(): number | undefined { + return this._currentValue; + } + + /** Getter for the Current Slider Values */ + get currentValues(): number[] | undefined { + return this._currentValues; + } + /** Getter to know what would be the default operator when none is specified */ get defaultOperator(): OperatorType | OperatorString { - return OperatorType.equal; + if (this.sliderType === 'compound') { + return OperatorType.empty; + } else if (this.sliderType === 'single') { + return OperatorType.equal; + } + return this.gridOptions.defaultFilterRangeOperator || OperatorType.rangeInclusive; } - /** Getter for the Filter Generic Params */ - protected get filterParams(): any { - return this.columnDef?.filter?.params ?? {}; + /** Getter for the Grid Options pulled through the Grid Object */ + get gridOptions(): GridOption { + return this.grid?.getOptions() ?? {}; + } + + /** Getter for the current Slider Options */ + get sliderOptions(): CurrentSliderOption | undefined { + return this._sliderOptions; } /** Getter for the Filter Operator */ get operator(): OperatorType | OperatorString { - return this.columnFilter?.operator ?? this.defaultOperator; + return this._operator || (this.columnFilter?.operator ?? this.defaultOperator); } /** Setter for the Filter Operator */ set operator(operator: OperatorType | OperatorString) { - if (this.columnFilter) { - this.columnFilter.operator = operator; - } + this._operator = operator; } - /** - * Initialize the Filter - */ + /** Initialize the Filter */ init(args: FilterArguments) { if (!args) { throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); @@ -75,185 +125,411 @@ export class SliderFilter implements Filter { this.grid = args.grid; this.callback = args.callback; this.columnDef = args.columnDef; + this.operator = args.operator || ''; this.searchTerms = args?.searchTerms ?? []; - this.filterContainerElm = args.filterContainerElm; - - // define the input & slider number IDs - this._elementRangeInputId = `rangeInput_${this.columnDef.field}`; - this._elementRangeOutputId = `rangeOutput_${this.columnDef.field}`; - - // filter input can only have 1 search term, so we will use the 1st array index if it exist - const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; + this._argFilterContainerElm = args.filterContainerElm; // step 1, create the DOM Element of the filter & initialize it if searchTerm is filled - this.filterElm = this.createDomFilterElement(searchTerm); - - // step 2, subscribe to the change event and run the callback when that happens - // also add/remove "filled" class for styling purposes - this._bindEventService.bind(this.filterInputElm, ['change', 'mouseup', 'touchend'], this.handleFilterChange.bind(this) as EventListener); - - // if user chose to display the slider number on the right side, then update it every time it changes - // we need to use both "input" and "change" event to support cross-browser - this._bindEventService.bind(this.filterInputElm, ['input', 'change'], this.handleInputChange.bind(this)); + this._filterElm = this.createDomFilterElement(this.searchTerms); } - /** - * Clear the filter value - */ + /** Clear the filter value */ clear(shouldTriggerQuery = true) { - if (this.filterElm) { + if (this._filterElm) { this._clearFilterTriggered = true; this._shouldTriggerQuery = shouldTriggerQuery; this.searchTerms = []; - const clearedValue = this.filterParams?.sliderStartValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE; - this._currentValue = +clearedValue; - this.filterInputElm.value = clearedValue; - if (this.filterNumberElm) { - this.filterNumberElm.textContent = clearedValue; + const lowestValue = +(this.getFilterOptionByName('sliderStartValue') ?? Constants.SLIDER_DEFAULT_MIN_VALUE) as number; + const highestValue = +(this.getFilterOptionByName('sliderEndValue') ?? Constants.SLIDER_DEFAULT_MAX_VALUE) as number; + + if (this.sliderType === 'double') { + if (this._sliderLeftElm) { + this._sliderLeftElm.value = `${lowestValue}`; + } + if (this._sliderRightElm) { + this._sliderRightElm.value = `${highestValue}`; + } + this._currentValues = [lowestValue, highestValue]; + this._sliderLeftElm?.dispatchEvent(new Event('change')); + this._sliderRightElm?.dispatchEvent(new Event('change')); + } else { + // for compound/single sliders, we'll only change to the lowest value + if (this._sliderRightElm) { + this._sliderRightElm.value = `${lowestValue}`; + } + if (this._selectOperatorElm) { + this._selectOperatorElm.selectedIndex = 0; // reset to empty Operator when included + } + this._currentValue = lowestValue; + this._sliderRightElm?.dispatchEvent(new Event('change')); } - this.filterElm.classList.remove('filled'); - this.divContainerFilterElm?.classList.remove('filled'); - this.filterInputElm.dispatchEvent(new Event('change')); + + const hideSliderNumbers = this.getFilterOptionByName('hideSliderNumber') ?? this.getFilterOptionByName('hideSliderNumbers'); + if (!hideSliderNumbers) { + if (this.sliderType === 'double') { + this.renderSliderValues(lowestValue, highestValue); + } else { + this.renderSliderValues(undefined, lowestValue); + } + } + this._divContainerFilterElm.classList.remove('filled'); + this._filterElm.classList.remove('filled'); + this.callback(undefined, { columnDef: this.columnDef, clearFilterTriggered: true, shouldTriggerQuery, searchTerms: [] }); } } - /** - * destroy the filter - */ + /** destroy the filter */ destroy() { this._bindEventService.unbindAll(); - emptyElement(this.filterElm); - this.filterElm?.remove?.(); } /** - * Get selected value retrieved from the slider element - * @params selected items + * Get option from filter.params PR filter.filterOptions + * @deprecated this should be removed when slider filterParams are replaced by filterOptions */ - getValues(): number | undefined { - return this._currentValue; + getFilterOptionByName(optionName: string, defaultValue?: string | number | boolean): T { + let outValue: string | number | boolean | undefined; + if (this.filterOptions?.[optionName as keyof SliderRangeOption] !== undefined) { + outValue = this.filterOptions[optionName as keyof SliderRangeOption]; + } else if (this.filterParams?.[optionName] !== undefined) { + console.warn('[Slickgrid-Universal] All filter.params were moved, and deprecated, to "filterOptions" as SliderRangeOption for better typing support.'); + outValue = this.filterParams?.[optionName]; + } + return outValue as T ?? defaultValue ?? undefined; + } + + /** + * Render both slider values (low/high) on screen + * @param leftValue number + * @param rightValue number + */ + renderSliderValues(leftValue?: number | string, rightValue?: number | string) { + if (this._leftSliderNumberElm?.textContent && leftValue) { + this._leftSliderNumberElm.textContent = leftValue.toString(); + } + if (this._rightSliderNumberElm?.textContent && rightValue) { + this._rightSliderNumberElm.textContent = rightValue.toString(); + } + } + + /** get current slider value(s), it could be a single value or an array of 2 values depending on the slider filter type */ + getValues() { + return this.sliderType === 'double' ? this._currentValues : this._currentValue; } - /** Set value(s) on the DOM element */ + /** + * Set value(s) on the DOM element + * @params searchTerms + */ setValues(values: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString) { - if (Array.isArray(values)) { - this.filterInputElm.value = `${values[0]}`; - if (this.filterNumberElm) { - this.filterNumberElm.textContent = `${values[0]}`; + if (values) { + let sliderVals: Array = []; + const term1: SearchTerm | undefined = Array.isArray(values) ? values?.[0] : values; + + if (Array.isArray(values) && values.length === 2) { + sliderVals = values as string[]; + } else { + if (typeof term1 === 'string' && (term1 as string).indexOf('..') > 0) { + sliderVals = (term1 as string).split('..'); + this._currentValue = +(sliderVals?.[0] ?? 0); + } else if (hasData(term1) || term1 === '') { + this._currentValue = +term1; + sliderVals = [term1 as string | number]; + } + } + + if (this.sliderType !== 'double' && this._sliderRightElm) { + this._sliderRightElm.value = typeof values === 'string' ? values : `${term1}`; + this.renderSliderValues(undefined, this._sliderRightElm.value); + } else if (Array.isArray(sliderVals) && sliderVals.length === 2) { + if (!this.getFilterOptionByName('hideSliderNumbers')) { + const [lowestValue, highestValue] = sliderVals; + if (this._sliderLeftElm) { + this._sliderLeftElm.value = String(lowestValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE); + } + if (this._sliderRightElm) { + this._sliderRightElm.value = String(highestValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE); + } + this.renderSliderValues(...sliderVals); + } } - this._currentValue = +values[0]; - } else if (hasData(values)) { - this.filterInputElm.value = `${values ?? ''}`; - this._currentValue = +values; } else { this._currentValue = undefined; - this.filterInputElm.value = ''; + this._currentValues = undefined; } - if (this.getValues() !== undefined) { - this.divContainerFilterElm?.classList.add('filled'); - this.filterElm.classList.add('filled'); - } else { - this.divContainerFilterElm?.classList.remove('filled'); - this.filterElm.classList.remove('filled'); - } + const val = this.getValues(); + const vals = val === undefined ? [] : Array.isArray(val) ? val : [val]; + (vals.length > 0) + ? this._filterElm.classList.add('filled') + : this._filterElm.classList.remove('filled'); // set the operator when defined - this.operator = operator || this.defaultOperator; + if (operator !== undefined) { + this.operator = operator; + } + if (this.operator && this._selectOperatorElm) { + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this._selectOperatorElm.value = operatorShorthand; + } } - // - // protected functions - // ------------------ - /** * Create the Filter DOM element + * Follows article with few modifications (without tooltip & neither slider track color) + * https://codingartistweb.com/2021/06/double-range-slider-html-css-javascript/ * @param searchTerm optional preset search terms */ - protected createDomFilterElement(searchTerm?: SearchTerm): HTMLDivElement { + protected createDomFilterElement(searchTerms?: SearchTerm | SearchTerm[]) { const columnId = this.columnDef?.id ?? ''; - const minValue = this.columnFilter?.minValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE; - const maxValue = this.columnFilter?.maxValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE; - const defaultValue = this.filterParams?.sliderStartValue ?? minValue; - const step = this.columnFilter?.valueStep ?? Constants.SLIDER_DEFAULT_STEP; - const startValue = +(this.filterParams?.sliderStartValue ?? minValue); - emptyElement(this.filterContainerElm); - - // create the DOM element & add an ID and filter class - let searchTermInput = (searchTerm || '0') as string; - if (+searchTermInput < minValue) { - searchTermInput = `${minValue}`; + const minValue = +(this.columnFilter?.minValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE); + const maxValue = +(this.columnFilter?.maxValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE); + const step = +(this.columnFilter?.valueStep ?? Constants.SLIDER_DEFAULT_STEP); + emptyElement(this._argFilterContainerElm); + + const defaultStartValue = +((Array.isArray(searchTerms) && searchTerms?.[0]) ?? this.getFilterOptionByName('sliderStartValue') ?? minValue); + const defaultEndValue = +((Array.isArray(searchTerms) && searchTerms?.[1]) ?? this.getFilterOptionByName('sliderEndValue') ?? maxValue); + + this._sliderRangeContainElm = createDomElement('div', { className: `filter-input filter-${columnId} slider-input-container slider-values` }); + this._sliderRangeContainElm.title = this.sliderType === 'double' ? `${defaultStartValue} - ${defaultEndValue}` : `${defaultStartValue}`; + this._sliderTrackElm = createDomElement('div', { className: 'slider-track' }); + + // create Operator dropdown DOM element + if (this.sliderType === 'compound') { + this._selectOperatorElm = buildSelectOperator(this.getOperatorOptionValues(), this.gridOptions); + const spanPrependElm = createDomElement('span', { className: 'input-group-addon input-group-prepend operator' }); + spanPrependElm.appendChild(this._selectOperatorElm); } - if (+searchTermInput < startValue) { - searchTermInput = `${startValue}`; + + // create 2nd (left) slider element to simulate a Slider Range with 2 handles + // the left slider represents min value slider, while right slider is for max value + if (this.sliderType === 'double') { + this._sliderLeftElm = createDomElement('input', { + type: 'range', + className: `slider-filter-input`, + ariaLabel: this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`, + defaultValue: `${defaultStartValue}`, value: `${defaultStartValue}`, + min: `${minValue}`, max: `${maxValue}`, step: `${step}`, + }); } - this._currentValue = +searchTermInput; - // create the DOM element - this.filterInputElm = createDomElement('input', { - type: 'range', name: this._elementRangeInputId, - className: `form-control slider-filter-input range ${this._elementRangeInputId}`, - defaultValue, value: searchTermInput, title: searchTermInput, + // right slider will be used by all Slider types + const rightDefaultVal = this.sliderType === 'double' ? defaultEndValue : defaultStartValue; + this._sliderRightElm = createDomElement('input', { + type: 'range', + className: `slider-filter-input`, + ariaLabel: this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`, + defaultValue: `${rightDefaultVal}`, value: `${rightDefaultVal}`, min: `${minValue}`, max: `${maxValue}`, step: `${step}`, }); - this.filterInputElm.setAttribute('aria-label', this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`); - this.divContainerFilterElm = createDomElement('div', { className: `search-filter slider-single slider-container filter-${columnId}` }); - this.divContainerFilterElm.appendChild(this.filterInputElm); + // put all DOM elements together to create the final Slider + const hideSliderNumbers = this.getFilterOptionByName('hideSliderNumber') ?? this.getFilterOptionByName('hideSliderNumbers'); + const sliderNumberClass = hideSliderNumbers ? '' : 'input-group'; + this._divContainerFilterElm = createDomElement('div', { className: `${sliderNumberClass} search-filter slider-container slider-values filter-${columnId}`.trim() }); - if (!this.filterParams.hideSliderNumber) { - this.divContainerFilterElm.classList.add('input-group'); - this.filterInputElm.value = searchTermInput; + this._sliderRangeContainElm.append(this._sliderTrackElm); + if (this.sliderType === 'double' && this._sliderLeftElm) { + this._sliderRangeContainElm.append(this._sliderLeftElm); + } + this._sliderRangeContainElm.append(this._sliderRightElm); - const divGroupAppendElm = createDomElement('div', { className: 'input-group-addon input-group-append slider-value' }); - this.filterNumberElm = createDomElement('span', { className: `input-group-text ${this._elementRangeOutputId}`, textContent: searchTermInput }); - divGroupAppendElm.appendChild(this.filterNumberElm); - this.divContainerFilterElm.appendChild(divGroupAppendElm); + if (hideSliderNumbers) { + this._divContainerFilterElm.append(this._sliderRangeContainElm); + } else { + let leftDivGroupElm: HTMLDivElement | HTMLSpanElement | undefined; + if (this.sliderType === 'compound' && this._selectOperatorElm) { + leftDivGroupElm = createDomElement('span', { className: 'input-group-addon input-group-prepend operator' }); + leftDivGroupElm.appendChild(this._selectOperatorElm); + } else if (this.sliderType === 'double') { + leftDivGroupElm = createDomElement('div', { className: `input-group-addon input-group-prepend slider-range-value` }); + this._leftSliderNumberElm = createDomElement('span', { className: `input-group-text lowest-range-${columnId}`, textContent: `${defaultStartValue}` }); + leftDivGroupElm.append(this._leftSliderNumberElm); + } + + const rightDivGroupElm = createDomElement('div', { className: `input-group-addon input-group-append slider-range-value` }); + this._rightSliderNumberElm = createDomElement('span', { className: `input-group-text highest-range-${columnId}`, textContent: `${rightDefaultVal}` }); + rightDivGroupElm.append(this._rightSliderNumberElm); + + if (leftDivGroupElm) { + this._divContainerFilterElm.append(leftDivGroupElm); + } + this._divContainerFilterElm.append(this._sliderRangeContainElm); + this._divContainerFilterElm.append(rightDivGroupElm); } - this.divContainerFilterElm.dataset.columnid = `${columnId}`; + // if we are preloading searchTerms, we'll keep them for reference + this._currentValue = defaultStartValue; + this._currentValues = [defaultStartValue, defaultEndValue]; + + // merge options with optional user's custom options + this._sliderOptions = { minValue, maxValue, step }; // if there's a search term, we will add the "filled" class for styling purposes - if (searchTerm) { - this.divContainerFilterElm.classList.add('filled'); + if (Array.isArray(searchTerms) && searchTerms.length > 0 && searchTerms[0] !== '') { + this._divContainerFilterElm.classList.add('filled'); } // append the new DOM element to the header row - this.filterContainerElm.appendChild(this.divContainerFilterElm); + this._argFilterContainerElm.append(this._divContainerFilterElm); + this.updateTrackFilledColor(); + + // attach events + this._bindEventService.bind(this._sliderTrackElm, 'click', this.sliderTrackClicked.bind(this) as EventListener); + this._bindEventService.bind(this._sliderRightElm, ['input', 'change'], this.slideRightInputChanged.bind(this)); + this._bindEventService.bind(this._sliderRightElm, ['change', 'mouseup', 'touchend'], this.onValueChanged.bind(this) as EventListener); + + if (this.sliderType === 'compound' && this._selectOperatorElm) { + this._bindEventService.bind(this._selectOperatorElm, ['change', 'mouseup', 'touchend'], this.onValueChanged.bind(this) as EventListener); + } else if (this.sliderType === 'double' && this._sliderLeftElm) { + this._bindEventService.bind(this._sliderLeftElm, ['input', 'change'], this.slideLeftInputChanged.bind(this)); + this._bindEventService.bind(this._sliderLeftElm, ['change', 'mouseup', 'touchend'], this.onValueChanged.bind(this) as EventListener); + } - return this.divContainerFilterElm; + return this._divContainerFilterElm; } - protected handleInputChange(event: Event) { - const value = (event?.target as HTMLInputElement).value; - if (value !== undefined && value !== null) { - if (!this.filterParams.hideSliderNumber && this.filterNumberElm?.textContent) { - this.filterNumberElm.textContent = value; - } - this.filterInputElm.title = value; + /** Get the available operator option values to populate the operator select dropdown list */ + protected getOperatorOptionValues(): OperatorDetail[] { + if (this.columnFilter?.compoundOperatorList) { + return this.columnFilter.compoundOperatorList; } + return compoundOperatorNumeric(this.gridOptions, this.translaterService); } - protected handleFilterChange(e: DOMEvent) { - const value = e?.target?.value ?? ''; - this._currentValue = +value; + /** handle value change event triggered, trigger filter callback & update "filled" class name */ + protected onValueChanged(e: MouseEvent) { + const sliderRightVal = parseInt(this._sliderRightElm?.value ?? '', 10); + let value; + let searchTerms: SearchTerm[]; + + if (this.sliderType === 'compound' || this.sliderType === 'single') { + this._currentValue = +sliderRightVal; + value = this._currentValue; + searchTerms = [value || '0']; + } else if (this.sliderType === 'double') { + const sliderLeftVal = parseInt(this._sliderLeftElm?.value ?? '', 10); + const values = [sliderLeftVal, sliderRightVal]; + value = values.join('..'); + searchTerms = values as SearchTerm[]; + } if (this._clearFilterTriggered) { - this.filterElm.classList.remove('filled'); + this._filterElm.classList.remove('filled'); this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, searchTerms: [], shouldTriggerQuery: this._shouldTriggerQuery }); } else { - value === '' ? this.filterElm.classList.remove('filled') : this.filterElm.classList.add('filled'); - this.callback(e, { columnDef: this.columnDef, searchTerms: (value ? [value || '0'] : null), operator: this.operator, shouldTriggerQuery: this._shouldTriggerQuery }); + const selectedOperator = (this._selectOperatorElm?.value ?? this.operator) as OperatorString; + value === '' ? this._filterElm.classList.remove('filled') : this._filterElm.classList.add('filled'); + this.callback(e, { columnDef: this.columnDef, operator: selectedOperator || '', searchTerms: searchTerms! as SearchTerm[], shouldTriggerQuery: this._shouldTriggerQuery }); } // reset both flags for next use this._clearFilterTriggered = false; this._shouldTriggerQuery = true; + this.changeBothSliderFocuses(false); // trigger mouse enter event on the filter for optionally hooked SlickCustomTooltip // the minimum requirements for tooltip to work are the columnDef and targetElement setTimeout(() => this.grid.onHeaderRowMouseEnter.notify( { column: this.columnDef, grid: this.grid }, - { ...new Slick.EventData(), target: this.filterContainerElm } + { ...new Slick.EventData(), target: this._argFilterContainerElm } )); } + + protected changeBothSliderFocuses(isAddingFocus: boolean) { + const addRemoveCmd = isAddingFocus ? 'add' : 'remove'; + this._sliderLeftElm?.classList[addRemoveCmd]('focus'); + this._sliderRightElm?.classList[addRemoveCmd]('focus'); + } + + protected slideLeftInputChanged() { + const sliderLeftVal = parseInt(this._sliderLeftElm?.value ?? '', 10); + const sliderRightVal = parseInt(this._sliderRightElm?.value ?? '', 10); + + if (this._sliderLeftElm && sliderRightVal - sliderLeftVal <= this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)) { + this._sliderLeftElm.value = String(sliderLeftVal - this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)); + } + + this._sliderRangeContainElm.title = this.sliderType === 'double' ? `${sliderLeftVal} - ${sliderRightVal}` : `${sliderRightVal}`; + + // change which handle has higher z-index to make them still usable, + // ie when left handle reaches the end, it has to have higher z-index or else it will be stuck below + // and we cannot move right because it cannot go below min value + if (this._sliderLeftElm && this._sliderRightElm) { + if (+this._sliderLeftElm.value >= +this._sliderRightElm.value - Z_INDEX_MIN_GAP) { + this._sliderLeftElm.style.zIndex = '1'; + this._sliderRightElm.style.zIndex = '0'; + } else { + this._sliderLeftElm.style.zIndex = '0'; + this._sliderRightElm.style.zIndex = '1'; + } + } + + this.updateTrackFilledColor(); + this.changeBothSliderFocuses(true); + const hideSliderNumbers = this.getFilterOptionByName('hideSliderNumber') ?? this.getFilterOptionByName('hideSliderNumbers'); + if (!hideSliderNumbers && this._leftSliderNumberElm?.textContent) { + this._leftSliderNumberElm.textContent = this._sliderLeftElm?.value ?? ''; + } + + } + + protected slideRightInputChanged() { + const sliderLeftVal = parseInt(this._sliderLeftElm?.value ?? '', 10); + const sliderRightVal = parseInt(this._sliderRightElm?.value ?? '', 10); + + if (this.sliderType === 'double' && this._sliderRightElm && sliderRightVal - sliderLeftVal <= this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)) { + this._sliderRightElm.value = String(sliderLeftVal + this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)); + } + + this.updateTrackFilledColor(); + this.changeBothSliderFocuses(true); + this._sliderRangeContainElm.title = this.sliderType === 'double' ? `${sliderLeftVal} - ${sliderRightVal}` : `${sliderRightVal}`; + + const hideSliderNumbers = this.getFilterOptionByName('hideSliderNumber') ?? this.getFilterOptionByName('hideSliderNumbers'); + if (!hideSliderNumbers && this._rightSliderNumberElm?.textContent) { + this._rightSliderNumberElm.textContent = this._sliderRightElm?.value ?? ''; + } + } + + protected sliderTrackClicked(e: MouseEvent) { + e.preventDefault(); + const sliderTrackX = e.offsetX; + const sliderTrackWidth = this._sliderTrackElm.offsetWidth; + const trackPercentPosition = (sliderTrackX + 0) * 100 / sliderTrackWidth; + + if (this._sliderRightElm && (this.sliderType === 'compound' || this.sliderType === 'single')) { + // when slider is compound/single, we'll automatically move to calculated clicked percentage + this._sliderRightElm.value = `${trackPercentPosition}`; + this._sliderRightElm.dispatchEvent(new Event('change')); + } else { + // when tracker position is below 50% we'll auto-place the left slider thumb or else auto-place right slider thumb + if (this._sliderLeftElm && this._sliderRightElm) { + if (trackPercentPosition <= 50) { + this._sliderLeftElm.value = `${trackPercentPosition}`; + this._sliderLeftElm.dispatchEvent(new Event('change')); + } else { + this._sliderRightElm.value = `${trackPercentPosition}`; + this._sliderRightElm.dispatchEvent(new Event('change')); + } + } + } + } + + protected updateTrackFilledColor() { + if (this.getFilterOptionByName('enableSliderTrackColoring') && this._sliderRightElm) { + let percent1 = 0; + if (this._sliderLeftElm) { + percent1 = ((+this._sliderLeftElm.value - +this._sliderLeftElm.min) / (this.sliderOptions?.maxValue ?? 0 - +this._sliderLeftElm.min)) * 100; + } + const percent2 = ((+this._sliderRightElm.value - +this._sliderRightElm.min) / (this.sliderOptions?.maxValue ?? 0 - +this._sliderRightElm.min)) * 100; + const bg = 'linear-gradient(to right, %b %p1, %c %p1, %c %p2, %b %p2)' + .replace(/%b/g, '#eee') + .replace(/%c/g, (this.getFilterOptionByName('sliderTrackFilledColor') ?? 'var(--slick-slider-filter-thumb-color, #86bff8)') as string) + .replace(/%p1/g, `${percent1}%`) + .replace(/%p2/g, `${percent2}%`); + + this._sliderTrackElm.style.background = bg; + this._sliderOptions.sliderTrackBackground = bg; + } + } } diff --git a/packages/common/src/filters/sliderRangeFilter.ts b/packages/common/src/filters/sliderRangeFilter.ts index 2e9c97a96..794f23272 100644 --- a/packages/common/src/filters/sliderRangeFilter.ts +++ b/packages/common/src/filters/sliderRangeFilter.ts @@ -1,421 +1,12 @@ -import { toSentenceCase } from '@slickgrid-universal/utils'; - -import { Constants } from '../constants'; -import { OperatorString, OperatorType, SearchTerm, } from '../enums/index'; -import { - Column, - ColumnFilter, - Filter, - FilterArguments, - FilterCallback, - GridOption, - SlickGrid, - SlickNamespace, - SliderRangeOption, -} from '../interfaces/index'; -import { BindingEventService } from '../services/bindingEvent.service'; -import { createDomElement, emptyElement } from '../services/domUtilities'; - -interface CurrentSliderOption { - minValue: number; - maxValue: number; - step: number; - sliderTrackBackground?: string; -} - -declare const Slick: SlickNamespace; -const GAP_BETWEEN_SLIDER_HANDLES = 0; -const Z_INDEX_MIN_GAP = 20; // gap in Px before we change z-index so that lowest/highest handle doesn't block each other - -/** A Slider Range Filter written in pure JS, this is only meant to be used as a range filter (with 2 handles lowest & highest values) */ -export class SliderRangeFilter implements Filter { - protected _bindEventService: BindingEventService; - protected _clearFilterTriggered = false; - protected _currentValues?: number[]; - protected _shouldTriggerQuery = true; - protected _sliderOptions!: CurrentSliderOption; - protected filterElm!: HTMLDivElement; - protected _argFilterContainerElm!: HTMLDivElement; - protected _divContainerFilterElm!: HTMLDivElement; - protected _filterContainerElm!: HTMLDivElement; - protected _leftSliderNumberElm?: HTMLSpanElement; - protected _rightSliderNumberElm?: HTMLSpanElement; - protected _sliderRangeContainElm!: HTMLDivElement; - protected _sliderTrackElm!: HTMLDivElement; - protected _sliderLeftElm!: HTMLInputElement; - protected _sliderRightElm!: HTMLInputElement; - grid!: SlickGrid; - searchTerms: SearchTerm[] = []; - columnDef!: Column; - callback!: FilterCallback; - - constructor() { - this._bindEventService = new BindingEventService(); - } - - /** @deprecated Getter for the Filter Generic Params */ - protected get filterParams(): any { - return this.columnDef?.filter?.params ?? {}; - } - - /** Getter for the Filter Options */ - get filterOptions(): SliderRangeOption | undefined { - return this.columnFilter.filterOptions; - } - - /** Getter for the Column Filter */ - get columnFilter(): ColumnFilter { - return this.columnDef?.filter ?? {}; - } - - /** Getter for the Current Slider Values */ - get currentValues(): number[] | undefined { - return this._currentValues; - } - - /** Getter to know what would be the default operator when none is specified */ - get defaultOperator(): OperatorType | OperatorString { - return this.gridOptions.defaultFilterRangeOperator || OperatorType.rangeInclusive; - } - - /** Getter for the Grid Options pulled through the Grid Object */ - get gridOptions(): GridOption { - return this.grid?.getOptions() ?? {}; - } - - /** Getter for the current Slider Options */ - get sliderRangeOptions(): CurrentSliderOption | undefined { - return this._sliderOptions; - } - - /** Getter for the Filter Operator */ - get operator(): OperatorType | OperatorString { - return this.columnFilter?.operator ?? this.defaultOperator; - } - - /** Setter for the Filter Operator */ - set operator(operator: OperatorType | OperatorString) { - if (this.columnFilter) { - this.columnFilter.operator = operator; - } - } +import { TranslaterService } from '../services'; +import { SliderFilter } from './sliderFilter'; +export class SliderRangeFilter extends SliderFilter { /** * Initialize the Filter */ - init(args: FilterArguments) { - if (!args) { - throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); - } - this.grid = args.grid; - this.callback = args.callback; - this.columnDef = args.columnDef; - this.searchTerms = args?.searchTerms ?? []; - this._argFilterContainerElm = args.filterContainerElm; - - // step 1, create the DOM Element of the filter & initialize it if searchTerm is filled - this.filterElm = this.createDomFilterElement(this.searchTerms); - } - - /** - * Clear the filter value - */ - clear(shouldTriggerQuery = true) { - if (this.filterElm) { - this._clearFilterTriggered = true; - this._shouldTriggerQuery = shouldTriggerQuery; - this.searchTerms = []; - const lowestValue = (this.getFilterOptionByName('sliderStartValue') ?? Constants.SLIDER_DEFAULT_MIN_VALUE) as number; - const highestValue = (this.getFilterOptionByName('sliderEndValue') ?? Constants.SLIDER_DEFAULT_MAX_VALUE) as number; - this._currentValues = [lowestValue, highestValue]; - this._sliderLeftElm.value = `${lowestValue}`; - this._sliderRightElm.value = `${highestValue}`; - this.dispatchBothEvents(); - - if (!this.getFilterOptionByName('hideSliderNumbers')) { - this.renderSliderValues(lowestValue, highestValue); - } - this._divContainerFilterElm.classList.remove('filled'); - this.filterElm.classList.remove('filled'); - this.callback(undefined, { columnDef: this.columnDef, clearFilterTriggered: true, shouldTriggerQuery }); - } - } - - /** - * destroy the filter - */ - destroy() { - this._bindEventService.unbindAll(); - } - - /** - * Get option from filter.params PR filter.filterOptions - * @deprecated this should be removed when slider filterParams are replaced by filterOptions - */ - getFilterOptionByName(optionName: string, defaultValue?: string | number | boolean): T { - let outValue: string | number | boolean | undefined; - if (this.filterOptions?.[optionName as keyof SliderRangeOption] !== undefined) { - outValue = this.filterOptions[optionName as keyof SliderRangeOption]; - } else if (this.filterParams?.[optionName] !== undefined) { - console.warn('[Slickgrid-Universal] All filter.params were moved, and deprecated, to "filterOptions" as SliderRangeOption for better typing support.'); - outValue = this.filterParams?.[optionName]; - } - return outValue as T ?? defaultValue ?? undefined; - } - - /** - * Render both slider values (low/high) on screen - * @param lowestValue number - * @param highestValue number - */ - renderSliderValues(lowestValue: number | string, highestValue: number | string) { - if (this._leftSliderNumberElm?.textContent) { - this._leftSliderNumberElm.textContent = lowestValue.toString(); - } - if (this._rightSliderNumberElm?.textContent) { - this._rightSliderNumberElm.textContent = highestValue.toString(); - } - } - - getValues() { - return this._currentValues; - } - - /** - * Set value(s) on the DOM element - * @params searchTerms - */ - setValues(searchTerms: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString) { - if (searchTerms) { - let sliderValues: number[] | string[] = []; - - // get the slider values, if it's a string with the "..", we'll do the split else we'll use the array of search terms - if (typeof searchTerms === 'string' || (Array.isArray(searchTerms) && typeof searchTerms[0] === 'string') && (searchTerms[0] as string).indexOf('..') > 0) { - sliderValues = (typeof searchTerms === 'string') ? [(searchTerms as string)] : (searchTerms[0] as string).split('..'); - } else if (Array.isArray(searchTerms)) { - sliderValues = searchTerms as string[]; - } - - if (Array.isArray(sliderValues) && sliderValues.length === 2) { - if (!this.getFilterOptionByName('hideSliderNumbers')) { - const [lowestValue, highestValue] = sliderValues; - this._sliderLeftElm.value = String(lowestValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE); - this._sliderRightElm.value = String(highestValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE); - this.renderSliderValues(sliderValues[0], sliderValues[1]); - } - } - } - - (searchTerms && (this.getValues?.() ?? []).length > 0) - ? this.filterElm.classList.add('filled') - : this.filterElm.classList.remove('filled'); - - // set the operator when defined - this.operator = operator || this.defaultOperator; - } - - /** - * Create the Filter DOM element - * Follows article with few modifications (without tooltip & neither slider track color) - * https://codingartistweb.com/2021/06/double-range-slider-html-css-javascript/ - * @param searchTerm optional preset search terms - */ - protected createDomFilterElement(searchTerms?: SearchTerm | SearchTerm[]) { - const columnId = this.columnDef?.id ?? ''; - const minValue = +(this.columnFilter?.minValue ?? Constants.SLIDER_DEFAULT_MIN_VALUE); - const maxValue = +(this.columnFilter?.maxValue ?? Constants.SLIDER_DEFAULT_MAX_VALUE); - const step = +(this.columnFilter?.valueStep ?? Constants.SLIDER_DEFAULT_STEP); - emptyElement(this._argFilterContainerElm); - - let defaultStartValue: number = Constants.SLIDER_DEFAULT_MIN_VALUE; - let defaultEndValue: number = Constants.SLIDER_DEFAULT_MAX_VALUE; - if (Array.isArray(searchTerms) && searchTerms.length > 1) { - defaultStartValue = +searchTerms[0]; - defaultEndValue = +searchTerms[1]; - } else { - defaultStartValue = +(this.getFilterOptionByName('sliderStartValue') ?? minValue); - defaultEndValue = +(this.getFilterOptionByName('sliderEndValue') ?? maxValue); - } - - this._sliderRangeContainElm = createDomElement('div', { className: `filter-input filter-${columnId} slider-range-container slider-values` }); - this._sliderRangeContainElm.title = `${defaultStartValue} - ${defaultEndValue}`; - - this._sliderTrackElm = createDomElement('div', { className: 'slider-track' }); - this._sliderLeftElm = createDomElement('input', { - type: 'range', - className: `slider-filter-input`, - ariaLabel: this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`, - defaultValue: `${defaultStartValue}`, value: `${defaultStartValue}`, - min: `${minValue}`, max: `${maxValue}`, step: `${step}`, - }); - this._sliderRightElm = createDomElement('input', { - type: 'range', - className: `slider-filter-input`, - ariaLabel: this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`, - defaultValue: `${defaultEndValue}`, value: `${defaultEndValue}`, - min: `${minValue}`, max: `${maxValue}`, step: `${step}`, - }); - - this._bindEventService.bind(this._sliderTrackElm, 'click', this.sliderTrackClicked.bind(this) as EventListener); - this._bindEventService.bind(this._sliderLeftElm, ['input', 'change'], this.slideLeftInputChanged.bind(this)); - this._bindEventService.bind(this._sliderRightElm, ['input', 'change'], this.slideRightInputChanged.bind(this)); - this._bindEventService.bind(this._sliderLeftElm, ['change', 'mouseup', 'touchend'], this.onValueChanged.bind(this) as EventListener); - this._bindEventService.bind(this._sliderRightElm, ['change', 'mouseup', 'touchend'], this.onValueChanged.bind(this) as EventListener); - - // create the DOM element - const sliderNumberClass = this.getFilterOptionByName('hideSliderNumbers') ? '' : 'input-group'; - this._divContainerFilterElm = createDomElement('div', { className: `${sliderNumberClass} search-filter slider-container slider-values filter-${columnId}`.trim() }); - - this._sliderRangeContainElm.append(this._sliderTrackElm); - this._sliderRangeContainElm.append(this._sliderLeftElm); - this._sliderRangeContainElm.append(this._sliderRightElm); - - if (this.getFilterOptionByName('hideSliderNumbers')) { - this._divContainerFilterElm.append(this._sliderRangeContainElm); - } else { - const lowestSliderContainerDivElm = createDomElement('div', { className: `input-group-addon input-group-prepend slider-range-value` }); - this._leftSliderNumberElm = createDomElement('span', { className: `input-group-text lowest-range-${columnId}`, textContent: `${defaultStartValue}` }); - lowestSliderContainerDivElm.append(this._leftSliderNumberElm); - - const highestSliderContainerDivElm = createDomElement('div', { className: `input-group-addon input-group-append slider-range-value` }); - this._rightSliderNumberElm = createDomElement('span', { className: `input-group-text highest-range-${columnId}`, textContent: `${defaultEndValue}` }); - highestSliderContainerDivElm.append(this._rightSliderNumberElm); - - this._divContainerFilterElm.append(lowestSliderContainerDivElm); - this._divContainerFilterElm.append(this._sliderRangeContainElm); - this._divContainerFilterElm.append(highestSliderContainerDivElm); - } - - // if we are preloading searchTerms, we'll keep them for reference - this._currentValues = [defaultStartValue, defaultEndValue]; - - // merge options with optional user's custom options - this._sliderOptions = { minValue, maxValue, step }; - - // if there's a search term, we will add the "filled" class for styling purposes - if (Array.isArray(searchTerms) && searchTerms.length > 0 && searchTerms[0] !== '') { - this._divContainerFilterElm.classList.add('filled'); - } - - // append the new DOM element to the header row - this._argFilterContainerElm.append(this._divContainerFilterElm); - this.updateTrackFilledColor(); - - return this._divContainerFilterElm; - } - - protected dispatchBothEvents() { - this._sliderLeftElm.dispatchEvent(new Event('change')); - this._sliderRightElm.dispatchEvent(new Event('change')); - } - - /** handle value change event triggered, trigger filter callback & update "filled" class name */ - protected onValueChanged(e: MouseEvent) { - const sliderLeftVal = parseInt(this._sliderLeftElm.value, 10); - const sliderRightVal = parseInt(this._sliderRightElm.value, 10); - const values = [sliderLeftVal, sliderRightVal]; - const value = values.join('..'); - - if (this._clearFilterTriggered) { - this.filterElm.classList.remove('filled'); - this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); - } else { - value === '' ? this.filterElm.classList.remove('filled') : this.filterElm.classList.add('filled'); - this.callback(e, { columnDef: this.columnDef, operator: this.operator, searchTerms: values, shouldTriggerQuery: this._shouldTriggerQuery }); - } - // reset both flags for next use - this._clearFilterTriggered = false; - this._shouldTriggerQuery = true; - this.changeBothSliderFocuses(false); - - // trigger mouse enter event on the filter for optionally hooked SlickCustomTooltip - // the minimum requirements for tooltip to work are the columnDef and targetElement - setTimeout(() => this.grid.onHeaderRowMouseEnter.notify( - { column: this.columnDef, grid: this.grid }, - { ...new Slick.EventData(), target: this._argFilterContainerElm } - )); - } - - protected changeBothSliderFocuses(isAddingFocus: boolean) { - const addRemoveCmd = isAddingFocus ? 'add' : 'remove'; - this._sliderLeftElm.classList[addRemoveCmd]('focus'); - this._sliderRightElm.classList[addRemoveCmd]('focus'); - } - - protected slideLeftInputChanged() { - const sliderLeftVal = parseInt(this._sliderLeftElm.value, 10); - const sliderRightVal = parseInt(this._sliderRightElm.value, 10); - - if (sliderRightVal - sliderLeftVal <= this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)) { - this._sliderLeftElm.value = String(sliderLeftVal - this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)); - } - - this._sliderRangeContainElm.title = `${sliderLeftVal} - ${sliderRightVal}`; - - // change which handle has higher z-index to make them still usable, - // ie when left handle reaches the end, it has to have higher z-index or else it will be stuck below - // and we cannot move right because it cannot go below min value - if (+this._sliderLeftElm.value >= +this._sliderRightElm.value - Z_INDEX_MIN_GAP) { - this._sliderLeftElm.style.zIndex = '1'; - this._sliderRightElm.style.zIndex = '0'; - } else { - this._sliderLeftElm.style.zIndex = '0'; - this._sliderRightElm.style.zIndex = '1'; - } - - this.updateTrackFilledColor(); - this.changeBothSliderFocuses(true); - if (!this.getFilterOptionByName('hideSliderNumbers') && this._leftSliderNumberElm?.textContent) { - this._leftSliderNumberElm.textContent = this._sliderLeftElm.value; - } - - } - - protected slideRightInputChanged() { - const sliderLeftVal = parseInt(this._sliderLeftElm.value, 10); - const sliderRightVal = parseInt(this._sliderRightElm.value, 10); - - if (sliderRightVal - sliderLeftVal <= this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)) { - this._sliderRightElm.value = String(sliderLeftVal + this.getFilterOptionByName('stopGapBetweenSliderHandles', GAP_BETWEEN_SLIDER_HANDLES)); - } - - this.updateTrackFilledColor(); - this.changeBothSliderFocuses(true); - this._sliderRangeContainElm.title = `${sliderLeftVal} - ${sliderRightVal}`; - - if (!this.getFilterOptionByName('hideSliderNumbers') && this._rightSliderNumberElm?.textContent) { - this._rightSliderNumberElm.textContent = this._sliderRightElm.value; - } - } - - protected sliderTrackClicked(e: MouseEvent) { - e.preventDefault(); - const sliderTrackX = e.offsetX; - const sliderTrackWidth = this._sliderTrackElm.offsetWidth; - const trackPercentPosition = (sliderTrackX + 0) * 100 / sliderTrackWidth; - - // when tracker position is below 50% we'll auto-place the left slider thumb or else auto-place right slider thumb - if (trackPercentPosition <= 50) { - this._sliderLeftElm.value = `${trackPercentPosition}`; - this._sliderLeftElm.dispatchEvent(new Event('change')); - } else { - this._sliderRightElm.value = `${trackPercentPosition}`; - this._sliderRightElm.dispatchEvent(new Event('change')); - } - } - - protected updateTrackFilledColor() { - if (this.getFilterOptionByName('enableSliderTrackColoring')) { - const percent1 = ((+this._sliderLeftElm.value - +this._sliderLeftElm.min) / (this.sliderRangeOptions?.maxValue ?? 0 - +this._sliderLeftElm.min)) * 100; - const percent2 = ((+this._sliderRightElm.value - +this._sliderRightElm.min) / (this.sliderRangeOptions?.maxValue ?? 0 - +this._sliderRightElm.min)) * 100; - const bg = 'linear-gradient(to right, %b %p1, %c %p1, %c %p2, %b %p2)' - .replace(/%b/g, '#eee') - .replace(/%c/g, (this.getFilterOptionByName('sliderTrackFilledColor') ?? 'var(--slick-slider-filter-thumb-color, #86bff8)') as string) - .replace(/%p1/g, `${percent1}%`) - .replace(/%p2/g, `${percent2}%`); - - this._sliderTrackElm.style.background = bg; - this._sliderOptions.sliderTrackBackground = bg; - } + constructor(protected readonly translaterService: TranslaterService) { + super(translaterService); + super.sliderType = 'double'; } } diff --git a/packages/common/src/interfaces/sliderOption.interface.ts b/packages/common/src/interfaces/sliderOption.interface.ts index c8013befc..44e1bf8f7 100644 --- a/packages/common/src/interfaces/sliderOption.interface.ts +++ b/packages/common/src/interfaces/sliderOption.interface.ts @@ -1,4 +1,7 @@ export interface SliderOption { + /** Defaults to false, do we want to show slider track coloring? */ + enableSliderTrackColoring?: boolean; + /** Defaults to true, hide the slider number shown on the right side */ hideSliderNumber?: boolean; @@ -7,18 +10,15 @@ export interface SliderOption { /** Slider min start value */ sliderStartValue?: number; -} -export interface SliderRangeOption extends Omit { - /** Defaults to false, do we want to show slider track coloring? */ - enableSliderTrackColoring?: boolean; - - /** Defaults to false, hide the slider numbers shown on the left/right side */ - hideSliderNumbers?: boolean; + /** Defaults to "#3C97DD", what will be the color to use to represent slider range */ + sliderTrackFilledColor?: string; /** Defaults to 0, minimum value gap before reaching the maximum end value */ stopGapBetweenSliderHandles?: number; +} - /** Defaults to "#3C97DD", what will be the color to use to represent slider range */ - sliderTrackFilledColor?: string; +export interface SliderRangeOption extends Omit { + /** Defaults to false, hide the slider numbers shown on the left/right side */ + hideSliderNumbers?: boolean; } \ No newline at end of file diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index d60030a7c..d5d51f554 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -966,31 +966,9 @@ input.flatpickr.form-control { } // ---------------------------------------------- -// Input Slider Filter (with vanilla html) +// Input Slider Filters (with vanilla html) // ---------------------------------------------- -.slider-single { - input[type="range"] { - &::-webkit-slider-runnable-track, &:focus::-webkit-slider-runnable-track { - background: var(--slick-slider-filter-bgcolor, $slick-slider-filter-bgcolor); - } - &::-moz-range-track, &:focus::-moz-range-track { - background: var(--slick-slider-filter-bgcolor, $slick-slider-filter-bgcolor); - } - &::-ms-fill-lower { - background: var(--slick-slider-filter-fill-lower-color, $slick-slider-filter-fill-lower-color); - &:focus { - background: var(--slick-slider-filter-fill-focus-lower-color, $slick-slider-filter-fill-focus-lower-color); - } - } - &::-ms-fill-upper, &:focus::-ms-fill-upper { - background: var(--slick-slider-filter-bgcolor, $slick-slider-filter-bgcolor); - } - &::-webkit-slider-runnable-track, &:focus::-webkit-slider-runnable-track { - background: var(--slick-slider-filter-bgcolor, $slick-slider-filter-bgcolor); - } - } -} input.slider-editor-input[type=range], input.slider-filter-input[type=range] { /*removes default webkit styles*/ @@ -1129,12 +1107,13 @@ input.slider-editor-input[type=range] { } } - // ---------------------------------------------- // Input Slider Range Filter (with vanilla html) // ---------------------------------------------- .slider-container { + display: flex; + .slider-range-value { padding: 0; height: 100%; @@ -1148,8 +1127,12 @@ input.slider-editor-input[type=range] { border-radius: var(--slick-slider-range-filter-border-radius, $slick-slider-range-filter-border-radius); } } + &.input-group > :first-child:not(.input-group-addon) .slider-filter-input { + border-bottom-left-radius: var(--slick-slider-range-filter-border-radius, $slick-slider-range-filter-border-radius); + border-top-left-radius: var(--slick-slider-range-filter-border-radius, $slick-slider-range-filter-border-radius); + } } -.slider-range-container { +.slider-input-container { position: relative; width: 100%; @@ -1199,7 +1182,7 @@ input.slider-editor-input[type=range] { } } -.slider-range-container.slider-values { +.slider-input-container.slider-values { display: flex; padding: 0; } diff --git a/test/cypress/e2e/example05.cy.js b/test/cypress/e2e/example05.cy.js index f6de60e9d..ac3ab6c82 100644 --- a/test/cypress/e2e/example05.cy.js +++ b/test/cypress/e2e/example05.cy.js @@ -29,7 +29,7 @@ describe('Example 05 - Tree Data (from a flat dataset with parentId references)' }); it('should have a Grid Preset Filter on 3rd column "% Complete" and expect all rows to be filtered as well', () => { - cy.get('.input-group-text.rangeOutput_percentComplete') + cy.get('.input-group-text.highest-range-percentComplete') .contains('25'); cy.get('.search-filter.filter-percentComplete') @@ -115,7 +115,7 @@ describe('Example 05 - Tree Data (from a flat dataset with parentId references)' cy.get('.search-filter.filter-percentComplete .operator .form-control') .should('have.value', '>='); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('25')); @@ -156,7 +156,7 @@ describe('Example 05 - Tree Data (from a flat dataset with parentId references)' cy.get('.search-filter.filter-percentComplete .operator .form-control') .should('have.value', ''); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('0')); @@ -230,7 +230,7 @@ describe('Example 05 - Tree Data (from a flat dataset with parentId references)' cy.get('.slick-viewport-top.slick-viewport-left') .scrollTo('top'); - + const now = new Date(); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const today = changeTimezone(now, tz); diff --git a/test/cypress/e2e/example11.cy.js b/test/cypress/e2e/example11.cy.js index 0c403525d..ee3bcf59f 100644 --- a/test/cypress/e2e/example11.cy.js +++ b/test/cypress/e2e/example11.cy.js @@ -197,7 +197,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { it('should not have filters set', () => { cy.get('.selected-view').should('contain', ''); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('0')); @@ -218,7 +218,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { .children() .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('50')); @@ -315,7 +315,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { .children() .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('0')); @@ -449,7 +449,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { cy.get('.slick-sort-indicator.slick-sort-indicator-desc') .should('have.length', 0); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('0')); @@ -497,7 +497,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { cy.get('.slick-sort-indicator.slick-sort-indicator-desc') .should('have.length', 0); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('0')); @@ -670,7 +670,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { .children() .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); - cy.get('.rangeInput_percentComplete') + cy.get('input.slider-filter-input') .invoke('val') .then(text => expect(text).to.eq('50'));