# Range

Range extends the shared field system and combines two coordinated native range inputs into one control. It supports min and max bounds, step snapping, formatted values, step markers, readonly and disabled states, helper and error text, and Angular forms integration.

## Import
```ts
import { RangeComponent, type NumericRange } from 'ui';
```

## Basic interval selection
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NumericRange, RangeComponent } from 'ui';

const dashedCardStyle =
  'padding:0.875rem 1rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest);min-width:12rem';

@Component({
  selector: 'app-range-basic-demo',
  standalone: true,
  imports: [FormsModule, RangeComponent],
  template: `
    <div
      style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start;width:100%;max-width:48rem;"
    >
      <div style="flex:1 1 18rem;min-width:16rem;max-width:30rem;">
        <ui-range
          label="Quiet hours"
          [min]="0"
          [max]="24"
          [step]="1"
          [showMinMax]="true"
          [formatValue]="formatHour"
          [(ngModel)]="quietHours"
          [ngModelOptions]="{ standalone: true }"
          [ariaValueText]="getAriaValueText"
        />
      </div>
      <div [attr.style]="dashedCardStyle">
        <p
          style="margin:0 0 0.5rem;font-size:0.75rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--color-neutral-foreground2-rest)"
        >
          Current range
        </p>
        <div style="display:grid;gap:0.5rem;font-size:0.875rem;">
          <div style="display:flex;justify-content:space-between;gap:1rem;">
            <span style="color:var(--color-neutral-foreground2-rest)">Start</span>
            <strong>{{ formatHour(quietHours.min) }}</strong>
          </div>
          <div style="display:flex;justify-content:space-between;gap:1rem;">
            <span style="color:var(--color-neutral-foreground2-rest)">End</span>
            <strong>{{ formatHour(quietHours.max) }}</strong>
          </div>
        </div>
      </div>
    </div>
  `,
})
export class RangeBasicDemoComponent {
  readonly dashedCardStyle = dashedCardStyle;

  protected quietHours: NumericRange = { min: 8, max: 18 };

  protected readonly formatHour = (value: number) => `${String(value).padStart(2, '0')}:00`;
  protected readonly getAriaValueText = (value: NumericRange) =>
    `From ${this.formatHour(value.min)} to ${this.formatHour(value.max)}`;
}
```

## Bounds, steps, and markers
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NumericRange, RangeComponent } from 'ui';

@Component({
  selector: 'app-range-bounds-steps-demo',
  standalone: true,
  imports: [FormsModule, RangeComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:40rem;">
      <ui-range
        label="Review score band"
        helpText="Whole-number ranges work well for discrete scoring scales."
        [min]="1"
        [max]="10"
        [step]="1"
        [showStepMarkers]="true"
        [showMinMax]="true"
        [(ngModel)]="scoreBand"
        [ngModelOptions]="{ standalone: true }"
      />

      <ui-range
        label="Budget threshold"
        helpText="Larger steps make the range easier to scan when precision is not critical."
        [min]="0"
        [max]="10000"
        [step]="500"
        [showStepMarkers]="true"
        [showMinMax]="true"
        [formatValue]="formatCurrency"
        [(ngModel)]="budgetBand"
        [ngModelOptions]="{ standalone: true }"
      />
    </div>
  `,
})
export class RangeBoundsStepsDemoComponent {
  protected scoreBand: NumericRange = { min: 3, max: 7 };
  protected budgetBand: NumericRange = { min: 2000, max: 6500 };

  protected readonly formatCurrency = (value: number) => `$${value.toLocaleString()}`;
}
```

## Formatting and min/max labels
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NumericRange, RangeComponent } from 'ui';

const dashedCardStyle =
  'padding:0.875rem 1rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest);min-width:14rem';

@Component({
  selector: 'app-range-formatting-demo',
  standalone: true,
  imports: [FormsModule, RangeComponent],
  template: `
    <div
      style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start;width:100%;max-width:50rem;"
    >
      <div
        style="flex:1 1 18rem;display:flex;min-width:16rem;max-width:32rem;flex-direction:column;gap:1rem;"
      >
        <ui-range
          label="Price filter"
          [min]="0"
          [max]="500"
          [step]="25"
          [showMinMax]="true"
          [formatValue]="formatPrice"
          [(ngModel)]="priceRange"
          [ngModelOptions]="{ standalone: true }"
          [ariaValueText]="formatPriceAria"
        />

        <ui-range
          label="Time estimate"
          [min]="15"
          [max]="240"
          [step]="15"
          [showMinMax]="true"
          [formatValue]="formatMinutes"
          [(ngModel)]="timeRange"
          [ngModelOptions]="{ standalone: true }"
        />
      </div>

      <div [attr.style]="dashedCardStyle">
        <p
          style="margin:0 0 0.5rem;font-size:0.75rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--color-neutral-foreground2-rest)"
        >
          Summary
        </p>
        <div style="display:grid;gap:0.5rem;font-size:0.875rem;">
          <div>{{ formatPriceAria(priceRange) }}</div>
          <div>{{ formatMinutes(timeRange.min) }} to {{ formatMinutes(timeRange.max) }}</div>
        </div>
      </div>
    </div>
  `,
})
export class RangeFormattingDemoComponent {
  readonly dashedCardStyle = dashedCardStyle;

  protected priceRange: NumericRange = { min: 75, max: 275 };
  protected timeRange: NumericRange = { min: 45, max: 150 };

  protected readonly formatPrice = (value: number) => `$${value}`;
  protected readonly formatPriceAria = (value: NumericRange) =>
    `Price from ${this.formatPrice(value.min)} to ${this.formatPrice(value.max)}`;
  protected readonly formatMinutes = (value: number) => `${value} min`;
}
```

## Readonly, disabled, and validation states
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NumericRange, RangeComponent } from 'ui';

@Component({
  selector: 'app-range-states-demo',
  standalone: true,
  imports: [FormsModule, RangeComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:40rem;">
      <ui-range
        label="Readonly policy window"
        helpText="Use readonly when the current band should stay visible but cannot be adjusted here."
        [readonly]="true"
        [showMinMax]="true"
        [formatValue]="formatHour"
        [(ngModel)]="readonlyRange"
        [ngModelOptions]="{ standalone: true }"
      />

      <ui-range
        label="Disabled limit"
        helpText="Disabled removes the control from active interaction."
        [disabled]="true"
        [showMinMax]="true"
        [(ngModel)]="disabledRange"
        [ngModelOptions]="{ standalone: true }"
      />

      <ui-range
        label="Approval band"
        helpText="Validation can explain why a range needs adjustment."
        errorText="The minimum and maximum values should be at least 20 points apart."
        [showMinMax]="true"
        [(ngModel)]="errorRange"
        [ngModelOptions]="{ standalone: true }"
      />
    </div>
  `,
})
export class RangeStatesDemoComponent {
  protected readonlyRange: NumericRange = { min: 9, max: 17 };
  protected disabledRange: NumericRange = { min: 25, max: 75 };
  protected errorRange: NumericRange = { min: 40, max: 50 };

  protected readonly formatHour = (value: number) => `${String(value).padStart(2, '0')}:00`;
}
```

## Reactive forms
```ts
import { Component, computed, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { RangeComponent, NumericRange } from 'ui';

function minimumGapValidator(gap: number): ValidatorFn {
  return control => {
    const value = control.value as NumericRange | null;
    if (!value) {
      return null;
    }

    return value.max - value.min >= gap ? null : { minimumGap: true };
  };
}

@Component({
  selector: 'app-range-reactive-form-demo',
  standalone: true,
  imports: [ReactiveFormsModule, RangeComponent, JsonPipe],
  template: `
    <div style="display:grid;gap:1rem;width:100%;max-width:44rem;">
      <form [formGroup]="form" style="display:grid;gap:1rem;">
        <ui-range
          label="Working budget"
          helpText="Keep at least a $1,000 band so the query stays meaningful."
          [showMinMax]="true"
          [formatValue]="formatCurrency"
          [errorText]="budgetError()"
          formControlName="budget"
        />
      </form>

      <div
        style="display:flex;flex-wrap:wrap;gap:12px;padding:12px 14px;border:1px dashed var(--color-neutral-stroke-rest);border-radius:12px;background:var(--color-neutral-background2-rest);"
      >
        <div style="font-weight:600;">Form value</div>
        <div>{{ form.value | json }}</div>
      </div>
    </div>
  `,
})
export class RangeReactiveFormDemoComponent {
  private readonly fb = inject(FormBuilder);

  protected readonly form = this.fb.group({
    budget: this.fb.nonNullable.control<NumericRange>({ min: 2000, max: 6000 }, [
      Validators.required,
      minimumGapValidator(1000),
    ]),
  });

  protected readonly formatCurrency = (value: number) => `$${value.toLocaleString()}`;

  protected readonly budgetError = computed(() => {
    const control = this.form.controls.budget;
    if (!control.touched && !control.dirty) {
      return '';
    }
    return control.hasError('minimumGap') ? 'Choose a wider budget band.' : '';
  });
}
```

## Filter panel composition
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NumericRange, RangeComponent, CheckboxComponent, ButtonComponent } from 'ui';

@Component({
  selector: 'app-range-filter-panel-demo',
  standalone: true,
  imports: [FormsModule, RangeComponent, CheckboxComponent, ButtonComponent],
  template: `
    <section
      style="display:grid;gap:1rem;max-width:52rem;padding:1.25rem;border:1px solid color-mix(in srgb,var(--color-neutral-stroke-rest) 70%,transparent);border-radius:1rem;background:color-mix(in srgb,var(--color-neutral-background-rest) 92%,white);"
    >
      <div style="display:grid;gap:0.25rem;">
        <h3 style="margin:0;font-size:1rem;">Analytics filters</h3>
        <p style="margin:0;color:var(--color-neutral-foreground2-rest);font-size:0.875rem;">
          Combine a numeric band with adjacent settings so the range feels like part of a real
          filter surface.
        </p>
      </div>

      <ui-range
        label="Revenue range"
        [min]="0"
        [max]="100000"
        [step]="5000"
        [showMinMax]="true"
        [showStepMarkers]="true"
        [formatValue]="formatCurrency"
        [(ngModel)]="revenueRange"
        [ngModelOptions]="{ standalone: true }"
      />

      <div style="display:flex;flex-wrap:wrap;gap:1rem;">
        <ui-checkbox
          label="Only active accounts"
          [(ngModel)]="onlyActive"
          [ngModelOptions]="{ standalone: true }"
        />
        <ui-checkbox
          label="Include trial users"
          [(ngModel)]="includeTrials"
          [ngModelOptions]="{ standalone: true }"
        />
      </div>

      <div
        style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:1rem;padding:0.875rem 1rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest);"
      >
        <div style="display:grid;gap:0.375rem;font-size:0.875rem;">
          <div>
            <strong>Revenue:</strong> {{ formatCurrency(revenueRange.min) }} to
            {{ formatCurrency(revenueRange.max) }}
          </div>
          <div><strong>Active only:</strong> {{ onlyActive ? 'Yes' : 'No' }}</div>
          <div><strong>Trials:</strong> {{ includeTrials ? 'Included' : 'Hidden' }}</div>
        </div>
        <div style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:flex-start;">
          <ui-button text="Reset" appearance="subtle" (click)="reset()" />
          <ui-button text="Apply filters" />
        </div>
      </div>
    </section>
  `,
})
export class RangeFilterPanelDemoComponent {
  protected revenueRange: NumericRange = { min: 20000, max: 70000 };
  protected onlyActive = true;
  protected includeTrials = false;

  protected readonly formatCurrency = (value: number) => `$${value.toLocaleString()}`;

  protected reset(): void {
    this.revenueRange = { min: 20000, max: 70000 };
    this.onlyActive = true;
    this.includeTrials = false;
  }
}
```

## Accessibility

### Accessible names and grouped semantics
The component exposes a grouped range surface and gives each thumb its own label derived from the visible field label. The lower thumb is announced as the minimum and the upper thumb as the maximum.

| Element | Accessibility behavior |
| --- | --- |
| group wrapper | labeled as a range group |
| lower thumb | labeled as the minimum value |
| upper thumb | labeled as the maximum value |
| formatted speech | can be customized with `ariaValueText` |

### Keyboard
| Key | Action |
| --- | --- |
| `ArrowLeft` / `ArrowDown` | decreases the focused thumb by one step |
| `ArrowRight` / `ArrowUp` | increases the focused thumb by one step |
| `PageDown` | decreases the focused thumb by a larger jump |
| `PageUp` | increases the focused thumb by a larger jump |
| `Home` | moves the focused thumb to the minimum |
| `End` | moves the focused thumb to the maximum |
| `Tab` | moves focus between thumbs and surrounding controls |

### Range value semantics
Each thumb exposes `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`. Use `ariaValueText` when the raw numbers need richer wording such as money, time, or human-readable windows.

| State | Attribute |
| --- | --- |
| current focused thumb value | `aria-valuenow` |
| shared bounds | `aria-valuemin` and `aria-valuemax` |
| richer wording | `aria-valuetext` |
| readonly | `aria-readonly="true"` |
