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 @@
+
+
+
+
+
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
+
+ -
+
NuiRangeFilterModule
+
+
+
+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 @@
+
+
+
+
+
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') {
+
+ } @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";