diff --git a/packages/bits/demo/src/components/app/app-routing.module.ts b/packages/bits/demo/src/components/app/app-routing.module.ts index cf0636bff..a82b9f7b0 100644 --- a/packages/bits/demo/src/components/app/app-routing.module.ts +++ b/packages/bits/demo/src/components/app/app-routing.module.ts @@ -194,6 +194,11 @@ const appRoutes: Routes = [ loadChildren: async () => import("../demo/radio-group/radio-group.module"), }, + { + path: "range-filter", + loadChildren: async () => + import("../demo/range-filter/range-filter.module"), + }, { path: "repeat", loadChildren: async () => import("../demo/repeat/repeat.module"), diff --git a/packages/bits/demo/src/components/demo/demo.files.ts b/packages/bits/demo/src/components/demo/demo.files.ts index 41cd2c849..fc74adf6b 100644 --- a/packages/bits/demo/src/components/demo/demo.files.ts +++ b/packages/bits/demo/src/components/demo/demo.files.ts @@ -962,6 +962,19 @@ export const DEMO_PATHS = [ "radio-group/radio-group.module.ts", "radio-group/value-change-radio-group/value-change-radio-group.example.component.html", "radio-group/value-change-radio-group/value-change-radio-group.example.component.ts", + "range-filter/index.ts", + "range-filter/range-filter-basic/range-filter-basic.example.component.html", + "range-filter/range-filter-basic/range-filter-basic.example.component.less", + "range-filter/range-filter-basic/range-filter-basic.example.component.ts", + "range-filter/range-filter-docs/range-filter-docs.example.component.html", + "range-filter/range-filter-docs/range-filter-docs.example.component.ts", + "range-filter/range-filter-guides/range-filter-guides.example.component.html", + "range-filter/range-filter-guides/range-filter-guides.example.component.less", + "range-filter/range-filter-guides/range-filter-guides.example.component.ts", + "range-filter/range-filter-vertical/range-filter-vertical.example.component.html", + "range-filter/range-filter-vertical/range-filter-vertical.example.component.less", + "range-filter/range-filter-vertical/range-filter-vertical.example.component.ts", + "range-filter/range-filter.module.ts", "repeat/index.ts", "repeat/repeat-disabled-multi-selection/repeat-disabled-multi-selection.example.component.html", "repeat/repeat-disabled-multi-selection/repeat-disabled-multi-selection.example.component.ts", diff --git a/packages/bits/demo/src/components/demo/range-filter/index.ts b/packages/bits/demo/src/components/demo/range-filter/index.ts new file mode 100644 index 000000000..59a06be24 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/index.ts @@ -0,0 +1,4 @@ +export * from "./range-filter-docs/range-filter-docs.example.component"; +export * from "./range-filter-basic/range-filter-basic.example.component"; +export * from "./range-filter-guides/range-filter-guides.example.component"; +export * from "./range-filter-vertical/range-filter-vertical.example.component"; diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.html b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.html new file mode 100644 index 000000000..bd4b58654 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.html @@ -0,0 +1,25 @@ +
+
+
+
Alert Tuning
+

CPU usage focus band

+

+ Pick the range you want the dashboard highlight to emphasize. +

+
+
+ {{ selectedRange.low }}% - {{ selectedRange.high }}% +
+
+ + +
diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.less b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.less new file mode 100644 index 000000000..545d2ff1c --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.less @@ -0,0 +1,48 @@ +.demo-card { + padding: 20px 24px; + border: 1px solid var(--nui-color-line-default); + border-radius: 12px; + background: linear-gradient( + 180deg, + var(--nui-color-background-default) 0%, + var(--nui-color-background-strong) 100% + ); + + &__header { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + } + + &__eyebrow { + margin-bottom: 4px; + color: var(--nui-color-text-secondary); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + &__title { + margin: 0; + font-size: 20px; + } + + &__subtitle { + margin: 8px 0 0; + max-width: 420px; + color: var(--nui-color-text-secondary); + } + + &__pill { + align-self: flex-start; + padding: 8px 12px; + border-radius: 999px; + background: var(--nui-color-background-accented); + color: var(--nui-color-text-default); + font-size: 14px; + font-weight: 700; + white-space: nowrap; + } +} \ No newline at end of file diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.ts b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.ts new file mode 100644 index 000000000..0d5bc38db --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-basic/range-filter-basic.example.component.ts @@ -0,0 +1,20 @@ +import { Component } from "@angular/core"; + +import { RangeValue } from "@nova-ui/bits"; + +@Component({ + selector: "nui-range-filter-basic-example", + templateUrl: "./range-filter-basic.example.component.html", + styleUrls: ["./range-filter-basic.example.component.less"], + standalone: false, +}) +export class RangeFilterBasicExampleComponent { + public selectedRange: RangeValue = { + low: 18, + high: 72, + }; + + public onRangeChange(value: RangeValue): void { + this.selectedRange = value; + } +} diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.html b/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.html new file mode 100644 index 000000000..73f3449fb --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.html @@ -0,0 +1,50 @@ +

Required Modules

+ + +

Basic Usage

+

+ The <nui-range-filter> component provides a draggable + range or single-value selector with keyboard support. Use the + rangeChange output to react to user input and feed the updated + value back through valueLow and valueHigh. +

+ + + + +

Guided Stepped Range

+

+ Enable guides to show step markers along the track. This works + well for bounded metric ranges where users should stay aligned to discrete + increments. +

+ + When guides is enabled, the component shows compact value + labels instead of number inputs. + + + + + +

Vertical Single Value

+

+ Use orientation="vertical" together with + mode="single" when the primary interaction is setting one + threshold rather than selecting a window. +

+ + + diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.ts b/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.ts new file mode 100644 index 000000000..1100bace4 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-docs/range-filter-docs.example.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "nui-range-filter-docs-example", + templateUrl: "./range-filter-docs.example.component.html", + standalone: false, +}) +export class RangeFilterDocsExampleComponent {} diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.html b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.html new file mode 100644 index 000000000..396ac634e --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.html @@ -0,0 +1,26 @@ +
+
+
+

Response-time budget

+

+ Discrete 25 ms steps make it easier to align the selected band + with alert thresholds. +

+
+
+ {{ selectedRange.low }} ms to {{ selectedRange.high }} ms +
+
+ + +
diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.less b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.less new file mode 100644 index 000000000..840702ac8 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.less @@ -0,0 +1,32 @@ +.metric-card { + padding: 20px 24px; + border-left: 4px solid var(--nui-color-active); + border-radius: 12px; + background: var(--nui-color-background-default); + box-shadow: inset 0 0 0 1px var(--nui-color-line-default); + + &__header { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + &__title { + margin: 0; + font-size: 18px; + } + + &__subtitle { + margin: 8px 0 0; + max-width: 420px; + color: var(--nui-color-text-secondary); + } + + &__legend { + align-self: flex-start; + font-weight: 700; + color: var(--nui-color-active); + white-space: nowrap; + } +} \ No newline at end of file diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.ts b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.ts new file mode 100644 index 000000000..3c590a73a --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-guides/range-filter-guides.example.component.ts @@ -0,0 +1,20 @@ +import { Component } from "@angular/core"; + +import { RangeValue } from "@nova-ui/bits"; + +@Component({ + selector: "nui-range-filter-guides-example", + templateUrl: "./range-filter-guides.example.component.html", + styleUrls: ["./range-filter-guides.example.component.less"], + standalone: false, +}) +export class RangeFilterGuidesExampleComponent { + public selectedRange: RangeValue = { + low: 125, + high: 350, + }; + + public onRangeChange(value: RangeValue): void { + this.selectedRange = value; + } +} diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.html b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.html new file mode 100644 index 000000000..28bb7c420 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.html @@ -0,0 +1,25 @@ +
+
+
Single Value Mode
+

Priority threshold

+

+ Use a vertical slider when the range itself is less important than a + single cut-off point. +

+
+ Level {{ threshold.high }} / 10 +
+
{{ thresholdLabel }}
+
+ + +
diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.less b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.less new file mode 100644 index 000000000..d8cd310bb --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.less @@ -0,0 +1,48 @@ +.threshold-demo { + display: flex; + align-items: center; + gap: 32px; + padding: 20px 24px; + border-radius: 12px; + background: + radial-gradient(circle at top left, rgba(15, 126, 209, 0.12), transparent 45%), + var(--nui-color-background-default); + box-shadow: inset 0 0 0 1px var(--nui-color-line-default); + + &__details { + max-width: 320px; + } + + &__eyebrow { + margin-bottom: 4px; + color: var(--nui-color-text-secondary); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + &__title { + margin: 0; + font-size: 18px; + } + + &__subtitle { + margin: 8px 0 16px; + color: var(--nui-color-text-secondary); + } + + &__status { + margin-bottom: 8px; + font-size: 24px; + font-weight: 700; + } + + &__badge { + display: inline-block; + padding: 6px 10px; + border-radius: 999px; + background: var(--nui-color-background-accented); + font-weight: 700; + } +} \ No newline at end of file diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.ts b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.ts new file mode 100644 index 000000000..b61615449 --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter-vertical/range-filter-vertical.example.component.ts @@ -0,0 +1,30 @@ +import { Component } from "@angular/core"; + +import { RangeValue } from "@nova-ui/bits"; + +@Component({ + selector: "nui-range-filter-vertical-example", + templateUrl: "./range-filter-vertical.example.component.html", + styleUrls: ["./range-filter-vertical.example.component.less"], + standalone: false, +}) +export class RangeFilterVerticalExampleComponent { + public threshold: RangeValue = { + low: 0, + high: 7, + }; + + public get thresholdLabel(): string { + if (this.threshold.high >= 8) { + return "Critical"; + } + if (this.threshold.high >= 5) { + return "Warning"; + } + return "Healthy"; + } + + public onRangeChange(value: RangeValue): void { + this.threshold = value; + } +} diff --git a/packages/bits/demo/src/components/demo/range-filter/range-filter.module.ts b/packages/bits/demo/src/components/demo/range-filter/range-filter.module.ts new file mode 100644 index 000000000..a90a28a0b --- /dev/null +++ b/packages/bits/demo/src/components/demo/range-filter/range-filter.module.ts @@ -0,0 +1,81 @@ +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { + DEMO_PATH_TOKEN, + NuiDocsModule, + NuiMessageModule, + NuiRangeFilterModule, + SrlcStage, +} from "@nova-ui/bits"; + +import { + RangeFilterBasicExampleComponent, + RangeFilterDocsExampleComponent, + RangeFilterGuidesExampleComponent, + RangeFilterVerticalExampleComponent, +} from "./index"; +import { getDemoFiles } from "../../../static/demo-files-factory"; + +const routes = [ + { + path: "", + component: RangeFilterDocsExampleComponent, + data: { + srlc: { + stage: SrlcStage.beta, + }, + showThemeSwitcher: true, + }, + }, + { + path: "basic", + component: RangeFilterBasicExampleComponent, + data: { + srlc: { + hideIndicator: true, + }, + }, + }, + { + path: "guides", + component: RangeFilterGuidesExampleComponent, + data: { + srlc: { + hideIndicator: true, + }, + }, + }, + { + path: "vertical", + component: RangeFilterVerticalExampleComponent, + data: { + srlc: { + hideIndicator: true, + }, + }, + }, +]; + +@NgModule({ + imports: [ + NuiDocsModule, + NuiMessageModule, + NuiRangeFilterModule, + RouterModule.forChild(routes), + ], + declarations: [ + RangeFilterBasicExampleComponent, + RangeFilterDocsExampleComponent, + RangeFilterGuidesExampleComponent, + RangeFilterVerticalExampleComponent, + ], + providers: [ + { + provide: DEMO_PATH_TOKEN, + useValue: getDemoFiles("range-filter"), + }, + ], + exports: [RouterModule], +}) +export default class RangeFilterModule {} diff --git a/packages/bits/e2e/components/public_api.ts b/packages/bits/e2e/components/public_api.ts index f81f16bf0..d64780ed8 100644 --- a/packages/bits/e2e/components/public_api.ts +++ b/packages/bits/e2e/components/public_api.ts @@ -37,6 +37,7 @@ export * from "./popover/popover.atom"; export * from "./popup/overlay-content.atom"; export * from "./popup/popup.atom"; export * from "./progress/progress.atom"; +export * from "./range-filter/range-filter.atom"; export * from "./search/search.atom"; export * from "./select/basic-select.atom"; export * from "./select/select.atom"; diff --git a/packages/bits/e2e/components/range-filter/range-filter.a11y.spec.ts b/packages/bits/e2e/components/range-filter/range-filter.a11y.spec.ts new file mode 100644 index 000000000..1d76a2c74 --- /dev/null +++ b/packages/bits/e2e/components/range-filter/range-filter.a11y.spec.ts @@ -0,0 +1,41 @@ +// © 2022 SolarWinds Worldwide, LLC. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { Helpers, test } from "../../setup"; +import { RangeFilterAtom } from "./range-filter.atom"; + +const routes = [ + "range-filter/basic", + "range-filter/guides", + "range-filter/vertical", +]; + +test.describe("a11y: range-filter", () => { + for (const route of routes) { + test(`should pass axe scan on ${route}`, async ({ + page, + runA11yScan, + }) => { + await Helpers.prepareBrowser(route, page); + + await runA11yScan(RangeFilterAtom); + }); + } +}); diff --git a/packages/bits/e2e/components/range-filter/range-filter.atom.ts b/packages/bits/e2e/components/range-filter/range-filter.atom.ts new file mode 100644 index 000000000..fd20249b4 --- /dev/null +++ b/packages/bits/e2e/components/range-filter/range-filter.atom.ts @@ -0,0 +1,95 @@ +// © 2022 SolarWinds Worldwide, LLC. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { Locator } from "@playwright/test"; + +import { Atom } from "../../atom"; +import { expect } from "../../setup"; + +export class RangeFilterAtom extends Atom { + public static CSS_SELECTOR = "nui-range-filter"; + + public readonly handles: Locator; + public readonly inputs: Locator; + + constructor(locator: Locator) { + super(locator); + this.handles = locator.getByRole("slider"); + this.inputs = locator.getByRole("spinbutton"); + } + + public get lowHandle(): Locator { + return this.handles.first(); + } + + public get highHandle(): Locator { + return this.handles.last(); + } + + public get minimumInput(): Locator { + return this.getLocator().getByRole("spinbutton", { + name: "Minimum value", + }); + } + + public get maximumInput(): Locator { + return this.getLocator().getByRole("spinbutton", { + name: "Maximum value", + }); + } + + public async expectHandleCount(count: number): Promise { + await expect(this.handles).toHaveCount(count); + } + + public async expectInputCount(count: number): Promise { + await expect(this.inputs).toHaveCount(count); + } + + public async expectLowValue(value: number): Promise { + await expect(this.lowHandle).toHaveAttribute( + "aria-valuenow", + String(value) + ); + } + + public async expectHighValue(value: number): Promise { + await expect(this.highHandle).toHaveAttribute( + "aria-valuenow", + String(value) + ); + } + + public async expectMinimumInputValue(value: number): Promise { + await expect(this.minimumInput).toHaveValue(String(value)); + } + + public async expectMaximumInputValue(value: number): Promise { + await expect(this.maximumInput).toHaveValue(String(value)); + } + + public async pressLowHandle(key: string): Promise { + await this.lowHandle.press(key); + } + + public async pressHighHandle(key: string): Promise { + await this.highHandle.press(key); + } +} diff --git a/packages/bits/e2e/components/range-filter/range-filter.e2e.spec.ts b/packages/bits/e2e/components/range-filter/range-filter.e2e.spec.ts new file mode 100644 index 000000000..16b0c6578 --- /dev/null +++ b/packages/bits/e2e/components/range-filter/range-filter.e2e.spec.ts @@ -0,0 +1,92 @@ +// © 2022 SolarWinds Worldwide, LLC. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { Atom } from "../../atom"; +import { expect, Helpers, test } from "../../setup"; +import { RangeFilterAtom } from "./range-filter.atom"; + +test.describe("USERCONTROL range-filter >", () => { + test("updates the basic range summary when the high handle moves by keyboard", async ({ + page, + }) => { + await Helpers.prepareBrowser("range-filter/basic", page); + + const demo = Helpers.page.getByTestId("test-range-filter-basic"); + const rangeFilter = Atom.findIn(RangeFilterAtom, demo); + + await rangeFilter.toBeVisible(); + await rangeFilter.expectHandleCount(2); + await rangeFilter.expectInputCount(2); + await rangeFilter.expectLowValue(18); + await rangeFilter.expectHighValue(72); + await rangeFilter.expectMinimumInputValue(18); + await rangeFilter.expectMaximumInputValue(72); + + await rangeFilter.pressHighHandle("ArrowRight"); + + await rangeFilter.expectHighValue(73); + await rangeFilter.expectMaximumInputValue(73); + await expect(demo).toContainText("18% - 73%"); + }); + + test("keeps the stepped guides demo summary in sync without exposing number inputs", async ({ + page, + }) => { + await Helpers.prepareBrowser("range-filter/guides", page); + + const demo = Helpers.page.getByTestId("test-range-filter-guides"); + const rangeFilter = Atom.findIn(RangeFilterAtom, demo); + + await rangeFilter.toBeVisible(); + await rangeFilter.expectHandleCount(2); + await rangeFilter.expectInputCount(0); + await rangeFilter.expectLowValue(125); + await rangeFilter.expectHighValue(350); + + await rangeFilter.pressLowHandle("ArrowLeft"); + + await rangeFilter.expectLowValue(100); + await expect(demo).toContainText("100 ms to 350 ms"); + }); + + test("updates the vertical single-value status and label when the threshold changes", async ({ + page, + }) => { + await Helpers.prepareBrowser("range-filter/vertical", page); + + const demo = Helpers.page.getByTestId("test-range-filter-vertical"); + const rangeFilter = Atom.findIn(RangeFilterAtom, demo); + + await rangeFilter.toBeVisible(); + await rangeFilter.expectHandleCount(1); + await rangeFilter.expectInputCount(1); + await rangeFilter.expectHighValue(7); + await rangeFilter.expectMaximumInputValue(7); + await expect(demo).toContainText("Level 7 / 10"); + await expect(demo).toContainText("Warning"); + + await rangeFilter.pressHighHandle("ArrowUp"); + + await rangeFilter.expectHighValue(8); + await rangeFilter.expectMaximumInputValue(8); + await expect(demo).toContainText("Level 8 / 10"); + await expect(demo).toContainText("Critical"); + }); +}); diff --git a/packages/bits/src/lib/public-api.ts b/packages/bits/src/lib/public-api.ts index f2e7a6df2..f47fdbcdb 100644 --- a/packages/bits/src/lib/public-api.ts +++ b/packages/bits/src/lib/public-api.ts @@ -37,6 +37,7 @@ export * from "./menu/public-api"; export * from "./paginator/public-api"; export * from "./panel/public-api"; export * from "./popover/public-api"; +export * from "./range-filter/public-api"; export * from "./repeat/public-api"; export * from "./select/public-api"; export * from "./select-v2/types"; diff --git a/packages/bits/src/lib/range-filter/public-api.ts b/packages/bits/src/lib/range-filter/public-api.ts new file mode 100644 index 000000000..efa92895e --- /dev/null +++ b/packages/bits/src/lib/range-filter/public-api.ts @@ -0,0 +1,3 @@ +export * from "./range-filter.component"; +export * from "./range-filter.models"; +export * from "./range-filter.module"; diff --git a/packages/bits/src/lib/range-filter/range-filter.component.html b/packages/bits/src/lib/range-filter/range-filter.component.html new file mode 100644 index 000000000..944ba238d --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.component.html @@ -0,0 +1,193 @@ +
+ @if (label()) { +
+ {{ label() }} + @if (unit()) { + + {{ unit() }} + + } +
+ } @if (showInputs()) { @if (orientation() === 'horizontal') { +
+ @if (mode() === 'range') { +
+ @if (guides()) { + {{ + displayLow() + }} + } @else { + + } +
+ } +
+ @if (guides()) { + {{ + displayHigh() + }} + } @else { + + } +
+
+ } @else { +
+ @if (guides()) { + {{ displayHigh() }} + } @else { + + } +
+ } } + +
+
+
+ + @for (dot of guideDots(); track dot) { +
+ } @if (mode() === 'range') { +
+ } + +
+
+
+ + @if (showInputs() && orientation() === 'vertical' && mode() === 'range') { +
+ @if (guides()) { + {{ displayLow() }} + } @else { + + } +
+ } + +
+ @if (dragHandle()) { @if (mode() === 'range') { + Range: {{ displayLow() }} to {{ displayHigh() }} + } @else { + Value: {{ displayHigh() }} + } } +
+
diff --git a/packages/bits/src/lib/range-filter/range-filter.component.less b/packages/bits/src/lib/range-filter/range-filter.component.less new file mode 100644 index 000000000..2deb3de8e --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.component.less @@ -0,0 +1,236 @@ +@handle-size: 16px; +@track-thickness: 2px; +@guide-dot-size: 5px; +@touch-area-extra: 14px; + +:host { + display: block; +} + +.nui-range-filter { + position: relative; + + &__label { + font-size: var(--nui-font-size-default); + font-weight: bold; + color: var(--nui-color-text-default); + margin-bottom: 8px; + + &--vertical { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 8px; + margin-bottom: 0; + text-align: center; + line-height: 1.2; + } + } + + &__unit { + margin-left: 4px; + font-size: var(--nui-font-size-default); + color: var(--nui-color-text-secondary); + font-weight: normal; + + &--stacked { + margin-left: 0; + margin-top: 2px; + display: block; + } + } + + &__track-wrapper { + padding: 12px 0; + } + + &__track { + position: relative; + height: @track-thickness; + background: var(--nui-color-line-default); + border-radius: calc(@track-thickness / 2); + cursor: pointer; + + .nui-range-filter--disabled & { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + } + + &__fill { + position: absolute; + height: 100%; + background: var(--nui-color-active); + border-radius: calc(@track-thickness / 2); + pointer-events: none; + + .nui-range-filter--disabled & { + background: var(--nui-color-disabled); + cursor: not-allowed; + } + } + + &__handle { + position: absolute; + top: 50%; + width: @handle-size; + height: @handle-size; + background: var(--nui-color-active); + border: 2px solid var(--nui-color-active); + border-radius: 50%; + transform: translate(-50%, -50%); + cursor: grab; + touch-action: none; + + &::before { + content: ""; + position: absolute; + top: -@touch-area-extra; + left: -@touch-area-extra; + right: -@touch-area-extra; + bottom: -@touch-area-extra; + } + + &:focus { + outline: 3px solid var(--nui-color-focus); + outline-offset: 2px; + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &--dragging { + cursor: grabbing; + } + + .nui-range-filter--disabled & { + background: var(--nui-color-disabled-secondary); + border-color: var(--nui-color-disabled); + cursor: not-allowed; + } + } + + &__guide-dot { + position: absolute; + top: 50%; + width: @guide-dot-size; + height: @guide-dot-size; + border-radius: 50%; + background: var(--nui-color-text-secondary); + transform: translate(-50%, -50%); + pointer-events: none; + } + + &__value-label { + display: inline-block; + width: 56px; + text-align: center; + font-size: var(--nui-font-size-default); + color: var(--nui-color-text-default); + font-variant-numeric: tabular-nums; + line-height: 28px; + } + + &__sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + } + + &__inputs { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + } + + &__input-group { + display: inline-flex; + width: 56px; + + &--end { + margin-left: auto; + } + + nui-textbox { + width: 56px; + min-width: 0; + + ::ng-deep .nui-textbox { + width: 66px; + min-width: 0; + } + + ::ng-deep input { + width: 100%; + min-width: 0; + } + } + } + + &--vertical { + display: flex; + flex-direction: column; + align-items: center; + + .nui-range-filter__label { + order: 5; + } + + .nui-range-filter__track-wrapper { + order: 2; + height: 200px; + width: 40px; + padding: 0 12px; + margin: 4px 0; + } + + .nui-range-filter__input-group--vert-top { + order: 1; + } + + .nui-range-filter__input-group--vert-bottom { + order: 4; + } + + .nui-range-filter__track { + height: 100%; + width: @track-thickness; + margin: 0 auto; + } + + .nui-range-filter__fill { + width: 100%; + height: auto; + left: 0; + right: 0; + } + + .nui-range-filter__handle { + left: 50%; + top: auto; + transform: translate(-50%, 50%); + } + + .nui-range-filter__guide-dot { + left: 50%; + top: auto; + transform: translate(-50%, 50%); + } + + .nui-range-filter__value-label { + width: auto; + line-height: 1.5; + } + + .nui-range-filter__input-group--vert { + display: flex; + justify-content: center; + margin: 0; + } + } +} \ No newline at end of file diff --git a/packages/bits/src/lib/range-filter/range-filter.component.spec.ts b/packages/bits/src/lib/range-filter/range-filter.component.spec.ts new file mode 100644 index 000000000..8ef2a2f70 --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.component.spec.ts @@ -0,0 +1,502 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { RangeFilterComponent } from "./range-filter.component"; +import { RangeValue } from "./range-filter.models"; + +describe("components >", () => { + describe("range filter >", () => { + let fixture: ComponentFixture; + let component: RangeFilterComponent; + + const setInputs = ( + overrides: Partial<{ + min: number; + max: number; + valueLow: number; + valueHigh: number; + step: number; + mode: "range" | "single"; + disabled: boolean; + label: string; + showInputs: boolean; + debounceMs: number; + }> + ): void => { + if (overrides.min !== undefined) { + fixture.componentRef.setInput("min", overrides.min); + } + if (overrides.max !== undefined) { + fixture.componentRef.setInput("max", overrides.max); + } + if (overrides.valueLow !== undefined) { + fixture.componentRef.setInput("valueLow", overrides.valueLow); + } + if (overrides.valueHigh !== undefined) { + fixture.componentRef.setInput("valueHigh", overrides.valueHigh); + } + if (overrides.step !== undefined) { + fixture.componentRef.setInput("step", overrides.step); + } + if (overrides.mode !== undefined) { + fixture.componentRef.setInput("mode", overrides.mode); + } + if (overrides.disabled !== undefined) { + fixture.componentRef.setInput("disabled", overrides.disabled); + } + if (overrides.label !== undefined) { + fixture.componentRef.setInput("label", overrides.label); + } + if (overrides.showInputs !== undefined) { + fixture.componentRef.setInput( + "showInputs", + overrides.showInputs + ); + } + if (overrides.debounceMs !== undefined) { + fixture.componentRef.setInput( + "debounceMs", + overrides.debounceMs + ); + } + fixture.detectChanges(); + }; + + const getHighHandle = () => + (fixture.debugElement.query( + By.css(".nui-range-filter__handle--high") + )?.nativeElement as HTMLElement | undefined) ?? null; + + const getLowHandle = () => + (fixture.debugElement.query( + By.css(".nui-range-filter__handle--low") + )?.nativeElement as HTMLElement | undefined) ?? null; + + const getInputs = () => + fixture.debugElement + .queryAll( + By.css(".nui-range-filter__input-group .nui-textbox__input") + ) + .map(({ nativeElement }) => nativeElement as HTMLInputElement); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RangeFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RangeFilterComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("debounceMs", 0); + fixture.detectChanges(); + }); + + it("renders one handle in single mode", () => { + setInputs({ mode: "single" }); + + expect(getLowHandle()).toBeNull(); + expect(getHighHandle()).toBeTruthy(); + }); + + it("renders two handles in range mode", () => { + setInputs({ mode: "range" }); + + expect(getLowHandle()).toBeTruthy(); + expect(getHighHandle()).toBeTruthy(); + }); + + it("renders inputs when showInputs is true", () => { + setInputs({ showInputs: true, mode: "range" }); + + expect(getInputs().length).toBe(2); + }); + + it("hides inputs when showInputs is false", () => { + setInputs({ showInputs: false }); + + expect(getInputs().length).toBe(0); + }); + + it("renders one input in single mode", () => { + setInputs({ showInputs: true, mode: "single" }); + + expect(getInputs().length).toBe(1); + }); + + it("marks component disabled when disabled input is true", () => { + setInputs({ disabled: true }); + + expect( + fixture.debugElement.query( + By.css(".nui-range-filter--disabled") + ) + ).toBeTruthy(); + }); + + it("marks component disabled when min equals max", () => { + setInputs({ min: 50, max: 50 }); + + expect( + fixture.debugElement.query( + By.css(".nui-range-filter--disabled") + ) + ).toBeTruthy(); + }); + + it("sets handles to tabindex -1 when disabled", () => { + setInputs({ disabled: true, mode: "range" }); + + expect(getHighHandle()?.getAttribute("tabindex")).toBe("-1"); + expect(getLowHandle()?.getAttribute("tabindex")).toBe("-1"); + }); + + it("clamps displayLow to min when valueLow is below min", () => { + setInputs({ min: 10, max: 100, valueLow: 0, valueHigh: 80 }); + + expect(component["displayLow"]()).toBe(10); + }); + + it("clamps displayHigh to max when valueHigh exceeds max", () => { + setInputs({ min: 0, max: 100, valueLow: 20, valueHigh: 150 }); + + expect(component["displayHigh"]()).toBe(100); + }); + + it("clamps displayHigh to min when both values are below min", () => { + setInputs({ min: 50, max: 100, valueLow: 0, valueHigh: 10 }); + + expect(component["displayHigh"]()).toBe(50); + }); + + it("clamps displayLow to valueHigh when valueLow exceeds valueHigh", () => { + setInputs({ min: 0, max: 100, valueLow: 90, valueHigh: 50 }); + + expect(component["displayLow"]()).toBe(50); + }); + + it("snaps displayHigh to nearest step", () => { + setInputs({ min: 0, max: 100, valueHigh: 13, step: 5 }); + + expect(component["displayHigh"]()).toBe(15); + }); + + it("snaps displayLow to nearest step", () => { + setInputs({ + min: 0, + max: 100, + valueLow: 12, + valueHigh: 80, + step: 5, + }); + + expect(component["displayLow"]()).toBe(10); + }); + + it("snaps values relative to min", () => { + setInputs({ + min: 3, + max: 23, + valueLow: 3, + valueHigh: 18, + step: 5, + }); + + expect(component["displayLow"]()).toBe(3); + expect(component["displayHigh"]()).toBe(18); + }); + + it("emits rangeChange immediately when debounceMs is 0", (done) => { + setInputs({ + min: 0, + max: 100, + valueLow: 20, + valueHigh: 80, + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(79); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }) + ); + }); + + it("debounces scheduled range changes and emits only the latest value", fakeAsync(() => { + setInputs({ debounceMs: 25 }); + const emitted: RangeValue[] = []; + + component.rangeChange.subscribe((value: RangeValue) => { + emitted.push(value); + }); + + component["emitDebounced"]({ low: 10, high: 40 }); + component["emitDebounced"]({ low: 15, high: 45 }); + + tick(24); + expect(emitted).toEqual([]); + + tick(1); + expect(emitted).toEqual([{ low: 15, high: 45 }]); + })); + + it("cancels a pending debounced emission when an immediate value is requested", fakeAsync(() => { + setInputs({ debounceMs: 25 }); + const emitted: RangeValue[] = []; + + component.rangeChange.subscribe((value: RangeValue) => { + emitted.push(value); + }); + + component["emitDebounced"]({ low: 10, high: 40 }); + component["emitImmediately"]({ low: 20, high: 50 }); + + expect(emitted).toEqual([{ low: 20, high: 50 }]); + + tick(25); + expect(emitted).toEqual([{ low: 20, high: 50 }]); + })); + + it("does not emit rangeChange when disabled", () => { + setInputs({ disabled: true, debounceMs: 0 }); + const spy = jasmine.createSpy("rangeChange"); + + component.rangeChange.subscribe(spy); + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }) + ); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("increments high value on ArrowRight", (done) => { + setInputs({ + min: 0, + max: 100, + valueLow: 20, + valueHigh: 50, + step: 1, + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(51); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }) + ); + }); + + it("decrements low value on ArrowLeft", (done) => { + setInputs({ + min: 0, + max: 100, + valueLow: 30, + valueHigh: 70, + step: 1, + mode: "range", + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.low).toBe(29); + done(); + }); + + getLowHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }) + ); + }); + + it("moves high to max on End key", (done) => { + setInputs({ min: 0, max: 100, valueHigh: 50, debounceMs: 0 }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(100); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { key: "End", bubbles: true }) + ); + }); + + it("moves high to min floor on Home key", (done) => { + setInputs({ + min: 0, + max: 100, + valueLow: 20, + valueHigh: 50, + mode: "range", + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(20); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Home", bubbles: true }) + ); + }); + + it("moves by large step on Shift+Arrow", (done) => { + setInputs({ + min: 0, + max: 200, + valueHigh: 50, + step: 5, + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(100); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + shiftKey: true, + bubbles: true, + }) + ); + }); + + it("keeps emitted values inside the declared range when step does not divide max", (done) => { + setInputs({ + min: 0, + max: 10, + valueHigh: 6, + step: 6, + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.high).toBe(10); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }) + ); + }); + + it("emits low:min in single mode regardless of valueLow", (done) => { + setInputs({ + min: 10, + max: 100, + valueLow: 40, + valueHigh: 70, + mode: "single", + debounceMs: 0, + }); + + component.rangeChange.subscribe((value: RangeValue) => { + expect(value.low).toBe(10); + done(); + }); + + getHighHandle()?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }) + ); + }); + + it("sets aria-valuemin, aria-valuemax, and aria-valuenow on the high handle", () => { + setInputs({ + min: 0, + max: 100, + valueLow: 20, + valueHigh: 60, + mode: "range", + }); + + expect(getHighHandle()?.getAttribute("aria-valuemin")).toBe("20"); + expect(getHighHandle()?.getAttribute("aria-valuemax")).toBe("100"); + expect(getHighHandle()?.getAttribute("aria-valuenow")).toBe("60"); + }); + + it("sets aria-valuemax on the low handle to displayHigh", () => { + setInputs({ + min: 0, + max: 100, + valueLow: 20, + valueHigh: 60, + mode: "range", + }); + + expect(getLowHandle()?.getAttribute("aria-valuemax")).toBe("60"); + expect(getLowHandle()?.getAttribute("aria-valuenow")).toBe("20"); + }); + + it("uses a unique label id for each instance", () => { + const firstFixture = TestBed.createComponent(RangeFilterComponent); + firstFixture.componentRef.setInput("label", "CPU usage"); + firstFixture.detectChanges(); + + const secondFixture = TestBed.createComponent(RangeFilterComponent); + secondFixture.componentRef.setInput("label", "Latency"); + secondFixture.detectChanges(); + + const firstLabelId = ( + firstFixture.debugElement.query( + By.css(".nui-range-filter__label") + ).nativeElement as HTMLElement + ).id; + const secondLabelId = ( + secondFixture.debugElement.query( + By.css(".nui-range-filter__label") + ).nativeElement as HTMLElement + ).id; + + expect(firstLabelId).not.toBe(secondLabelId); + expect(firstLabelId).toContain("nui-range-filter-label-"); + expect(secondLabelId).toContain("nui-range-filter-label-"); + + firstFixture.destroy(); + secondFixture.destroy(); + }); + + it("preserves distinct thumb labels when a visible label is present", () => { + setInputs({ label: "CPU usage", mode: "range" }); + + expect(getLowHandle()?.getAttribute("aria-label")).toBe( + "CPU usage, Minimum value" + ); + expect(getHighHandle()?.getAttribute("aria-label")).toBe( + "CPU usage, Maximum value" + ); + expect(getLowHandle()?.hasAttribute("aria-labelledby")).toBeFalse(); + expect( + getHighHandle()?.hasAttribute("aria-labelledby") + ).toBeFalse(); + }); + }); +}); diff --git a/packages/bits/src/lib/range-filter/range-filter.component.ts b/packages/bits/src/lib/range-filter/range-filter.component.ts new file mode 100644 index 000000000..71734b9e1 --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.component.ts @@ -0,0 +1,415 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostListener, + OnDestroy, + ViewChild, + computed, + inject, + input, + output, + signal, +} from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { Subject, of } from "rxjs"; +import { debounce, delay } from "rxjs/operators"; + +import { RangeValue } from "./range-filter.models"; +import { NuiTextboxModule } from "../textbox/textbox.module"; + +interface RangeChangeRequest { + debounceMs: number; + immediate: boolean; + value: RangeValue; +} + +@Component({ + selector: "nui-range-filter", + templateUrl: "./range-filter.component.html", + styleUrls: ["./range-filter.component.less"], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, FormsModule, NuiTextboxModule], +}) +export class RangeFilterComponent implements OnDestroy { + private static nextLabelId = 0; + + public readonly min = input(0); + public readonly max = input(100); + public readonly valueLow = input(0); + public readonly valueHigh = input(100); + public readonly step = input(1); + public readonly orientation = input<"horizontal" | "vertical">( + "horizontal" + ); + public readonly mode = input<"range" | "single">("range"); + public readonly disabled = input(false); + public readonly label = input(undefined); + public readonly unit = input(undefined); + public readonly showInputs = input(true); + public readonly guides = input(false); + public readonly debounceMs = input(300); + + public readonly rangeChange = output(); + + protected readonly labelId = `nui-range-filter-label-${RangeFilterComponent.nextLabelId++}`; + protected readonly dragHandle = signal<"low" | "high" | null>(null); + protected readonly resolvedLow = computed(() => { + const resolvedHigh = this.snapAndClamp( + this.valueHigh(), + this.min(), + this.max() + ); + + return this.snapAndClamp(this.valueLow(), this.min(), resolvedHigh); + }); + protected readonly resolvedHigh = computed(() => + this.snapAndClamp(this.valueHigh(), this.resolvedLow(), this.max()) + ); + protected readonly displayLow = computed(() => { + if (this.dragHandle() !== null) { + return this.liveLow(); + } + + return this.resolvedLow(); + }); + protected readonly displayHigh = computed(() => { + if (this.dragHandle() !== null) { + return this.liveHigh(); + } + + return this.resolvedHigh(); + }); + protected readonly isDisabled = computed( + () => this.disabled() || this.min() === this.max() + ); + protected readonly lowPct = computed(() => this.toPct(this.displayLow())); + protected readonly highPct = computed(() => this.toPct(this.displayHigh())); + protected readonly fillStart = computed(() => + this.mode() === "single" ? 0 : this.lowPct() + ); + protected readonly fillSize = computed( + () => this.highPct() - this.fillStart() + ); + protected readonly guideDots = computed((): number[] => { + if (!this.guides()) { + return []; + } + + const min = this.min(); + const max = this.max(); + const step = this.step(); + if (step <= 0 || max <= min) { + return []; + } + + const count = Math.round((max - min) / step); + if (count > 100) { + return []; + } + + const range = max - min; + const activeStart = this.fillStart(); + const activeEnd = activeStart + this.fillSize(); + const dots: number[] = []; + + for (let i = 0; i <= count; i++) { + const pct = ((i * step) / range) * 100; + if (pct < activeStart || pct > activeEnd) { + dots.push(pct); + } + } + + return dots; + }); + + private readonly liveLow = signal(0); + private readonly liveHigh = signal(100); + private readonly rangeChangeRequests$ = new Subject(); + private readonly rangeChangeSubscription = this.rangeChangeRequests$ + .pipe( + debounce(({ immediate, debounceMs }) => + immediate || debounceMs <= 0 + ? of(null) + : of(null).pipe(delay(debounceMs)) + ) + ) + .subscribe(({ value }) => { + this.rangeChange.emit(value); + }); + private readonly cdr = inject(ChangeDetectorRef); + @ViewChild("trackEl") private readonly trackEl?: ElementRef; + + private dragPointerOffset = 0; + + public ngOnDestroy(): void { + this.rangeChangeSubscription.unsubscribe(); + this.rangeChangeRequests$.complete(); + } + + protected onLowKeydown(event: KeyboardEvent): void { + if (this.isDisabled() || this.mode() === "single") { + return; + } + + const action = this.keyAction(event); + if (action === null) { + return; + } + + event.preventDefault(); + let next: number; + if (action === "min") { + next = this.min(); + } else if (action === "max") { + next = this.displayHigh(); + } else { + next = this.displayLow() + action; + } + + next = this.snapAndClamp(next, this.min(), this.displayHigh()); + this.emitDebounced({ low: next, high: this.displayHigh() }); + } + + protected onHighKeydown(event: KeyboardEvent): void { + if (this.isDisabled()) { + return; + } + + const action = this.keyAction(event); + if (action === null) { + return; + } + + event.preventDefault(); + const floor = this.mode() === "single" ? this.min() : this.displayLow(); + let next: number; + if (action === "min") { + next = floor; + } else if (action === "max") { + next = this.max(); + } else { + next = this.displayHigh() + action; + } + + next = this.snapAndClamp(next, floor, this.max()); + this.emitDebounced({ + low: this.mode() === "single" ? this.min() : this.displayLow(), + high: next, + }); + } + + protected onHandleMousedown( + event: MouseEvent, + handle: "low" | "high" + ): void { + if (this.isDisabled()) { + return; + } + + event.preventDefault(); + (event.currentTarget as HTMLElement).focus(); + + const currentLow = this.displayLow(); + const currentHigh = this.displayHigh(); + this.liveLow.set(currentLow); + this.liveHigh.set(currentHigh); + this.dragHandle.set(handle); + + const track = this.trackEl?.nativeElement; + if (!track) { + this.dragPointerOffset = 0; + return; + } + + const rect = track.getBoundingClientRect(); + const handlePct = + this.toPct(handle === "low" ? currentLow : currentHigh) / 100; + this.dragPointerOffset = + this.orientation() === "horizontal" + ? event.clientX - (rect.left + handlePct * rect.width) + : event.clientY - (rect.bottom - handlePct * rect.height); + } + + @HostListener("document:mousemove", ["$event"]) + protected onDocumentMousemove(event: MouseEvent): void { + if (this.dragHandle() === null) { + return; + } + + const value = this.eventToValue(event, this.dragPointerOffset); + if (this.dragHandle() === "low") { + this.liveLow.set( + this.snapAndClamp(value, this.min(), this.liveHigh()) + ); + } else { + const floor = + this.mode() === "single" ? this.min() : this.liveLow(); + this.liveHigh.set(this.snapAndClamp(value, floor, this.max())); + } + + this.cdr.markForCheck(); + } + + @HostListener("document:mouseup") + protected onDocumentMouseup(): void { + if (this.dragHandle() === null) { + return; + } + + const low = this.mode() === "single" ? this.min() : this.liveLow(); + const high = this.liveHigh(); + this.dragHandle.set(null); + this.emitImmediately({ low, high }); + this.cdr.markForCheck(); + } + + protected onTrackClick(event: MouseEvent): void { + if (this.isDisabled()) { + return; + } + + const value = this.eventToValue(event); + const distHigh = Math.abs(value - this.displayHigh()); + const distLow = Math.abs(value - this.displayLow()); + if (this.mode() === "single" || distHigh <= distLow) { + const floor = + this.mode() === "single" ? this.min() : this.displayLow(); + const next = this.snapAndClamp(value, floor, this.max()); + this.emitDebounced({ + low: this.mode() === "single" ? this.min() : this.displayLow(), + high: next, + }); + return; + } + + const next = this.snapAndClamp(value, this.min(), this.displayHigh()); + this.emitDebounced({ low: next, high: this.displayHigh() }); + } + + protected onLowInputChange(rawValue: string): void { + if (this.isDisabled()) { + return; + } + + const next = this.clampAndSnap( + rawValue, + this.min(), + this.displayHigh() + ); + this.emitDebounced({ low: next, high: this.displayHigh() }); + } + + protected onHighInputChange(rawValue: string): void { + if (this.isDisabled()) { + return; + } + + const floor = this.mode() === "single" ? this.min() : this.displayLow(); + const next = this.clampAndSnap(rawValue, floor, this.max()); + this.emitDebounced({ + low: this.mode() === "single" ? this.min() : this.displayLow(), + high: next, + }); + } + + private keyAction(event: KeyboardEvent): number | "min" | "max" | null { + const isHorizontal = this.orientation() === "horizontal"; + const incrementKey = isHorizontal ? "ArrowRight" : "ArrowUp"; + const decrementKey = isHorizontal ? "ArrowLeft" : "ArrowDown"; + const largeStep = this.step() * 10; + + if (event.key === incrementKey) { + return event.shiftKey ? largeStep : this.step(); + } + if (event.key === decrementKey) { + return event.shiftKey ? -largeStep : -this.step(); + } + if (event.key === "Home") { + return "min"; + } + if (event.key === "End") { + return "max"; + } + + return null; + } + + private eventToValue(event: MouseEvent, pointerOffset = 0): number { + const track = this.trackEl?.nativeElement; + if (!track) { + return this.min(); + } + + const rect = track.getBoundingClientRect(); + const pct = + this.orientation() === "horizontal" + ? Math.max( + 0, + Math.min( + 1, + (event.clientX - pointerOffset - rect.left) / + rect.width + ) + ) + : Math.max( + 0, + Math.min( + 1, + 1 - + (event.clientY - pointerOffset - rect.top) / + rect.height + ) + ); + + return this.min() + pct * (this.max() - this.min()); + } + + private toPct(value: number): number { + const range = this.max() - this.min(); + + return range === 0 ? 0 : ((value - this.min()) / range) * 100; + } + + private snap(value: number): number { + const step = this.step(); + const min = this.min(); + + return step > 0 ? min + Math.round((value - min) / step) * step : value; + } + + private snapAndClamp(value: number, low: number, high: number): number { + const bounded = Math.max(low, Math.min(value, high)); + + return Math.max(low, Math.min(this.snap(bounded), high)); + } + + private clampAndSnap(rawValue: string, low: number, high: number): number { + const parsed = parseFloat(rawValue); + + return this.snapAndClamp( + Number.isNaN(parsed) ? low : parsed, + low, + high + ); + } + + private emitDebounced(value: RangeValue): void { + this.rangeChangeRequests$.next({ + debounceMs: this.debounceMs(), + immediate: false, + value, + }); + } + + private emitImmediately(value: RangeValue): void { + this.rangeChangeRequests$.next({ + debounceMs: 0, + immediate: true, + value, + }); + } +} diff --git a/packages/bits/src/lib/range-filter/range-filter.models.ts b/packages/bits/src/lib/range-filter/range-filter.models.ts new file mode 100644 index 000000000..84e47772e --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.models.ts @@ -0,0 +1,4 @@ +export interface RangeValue { + low: number; + high: number; +} diff --git a/packages/bits/src/lib/range-filter/range-filter.module.ts b/packages/bits/src/lib/range-filter/range-filter.module.ts new file mode 100644 index 000000000..83c339329 --- /dev/null +++ b/packages/bits/src/lib/range-filter/range-filter.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { RangeFilterComponent } from "./range-filter.component"; + +@NgModule({ + imports: [RangeFilterComponent], + exports: [RangeFilterComponent], +}) +export class NuiRangeFilterModule {} diff --git a/packages/bits/src/nui-api.ts b/packages/bits/src/nui-api.ts index 4e53411e5..f4baf3017 100644 --- a/packages/bits/src/nui-api.ts +++ b/packages/bits/src/nui-api.ts @@ -56,6 +56,7 @@ export { NuiPanelModule } from "./lib/panel/panel.module"; export { NuiPopoverModule } from "./lib/popover/popover.module"; export { NuiProgressModule } from "./lib/progress/progress.module"; export { NuiRadioModule } from "./lib/radio/radio.module"; +export { NuiRangeFilterModule } from "./lib/range-filter/range-filter.module"; export { NuiRepeatModule } from "./lib/repeat/repeat.module"; export { NuiRiskScoreModule } from "./lib/risk-score/risk-score.module"; export { NuiSearchModule } from "./lib/search/search.module"; diff --git a/packages/bits/src/public_api.ts b/packages/bits/src/public_api.ts index 50bb94250..9b2277a32 100644 --- a/packages/bits/src/public_api.ts +++ b/packages/bits/src/public_api.ts @@ -46,6 +46,7 @@ export { ButtonComponent } from "./lib/button/button.component"; export { BusyComponent } from "./lib/busy/busy.component"; export { ImageComponent } from "./lib/image/image.component"; export { SearchComponent } from "./lib/search/search.component"; +export { RangeFilterComponent } from "./lib/range-filter/range-filter.component"; export { TextboxComponent } from "./lib/textbox/textbox.component"; export { RepeatItemComponent } from "./lib/repeat/repeat-item/repeat-item.component"; export { RepeatComponent } from "./lib/repeat/repeat.component";