Skip to main content
Urbicon UI
Back to Recipes

Range Hint Input

A number field whose helper text names the expected range and follows the typed value: success inside it, warning just outside, danger far outside. Nothing blocks: a far-off value stays enterable, it just reads as unusual.

Built with Input

Live preview

ReportPage.svelte

Try 12500, 11800 and 99 in the meter field to get all three verdicts: plausible, slightly outside, unusual.
Expected: 12,000 kWh–13,500 kWh.
Expected: €800–€1,200.
Expected: 7 h–9 h.
<script lang="ts">
  import { Input } from '@urbicon-ui/blocks';

  // Input's own intent names: a status feeds intent with no mapping layer.
  type RangeStatus = 'default' | 'success' | 'warning' | 'danger';

  // One config per field, two shared functions: a new plausibility field is
  // another config object, not a new component.
  interface RangeHintConfig {
    label: string;
    expectedRange: [number, number];
    tolerancePercent?: number;
    helpOnDanger?: string;
    formatRange?: (n: number) => string;
  }

  const meterConfig: RangeHintConfig = {
    label: 'Heating meter reading',
    expectedRange: [12000, 13500],
    tolerancePercent: 15,
    helpOnDanger: 'Typo or meter replaced?',
    formatRange: (n) => n.toLocaleString('en-US') + ' kWh'
  };
  let meterValue = $state<number | null>(null);

  const budgetConfig: RangeHintConfig = {
    label: 'Planned expense (€)',
    expectedRange: [800, 1200],
    tolerancePercent: 10,
    helpOnDanger: 'One-off purchase, or a typo?',
    formatRange: (n) => '' + n.toLocaleString('en-US')
  };
  let budgetValue = $state<number | null>(null);

  const timeConfig: RangeHintConfig = {
    label: 'Hours on the project this week',
    expectedRange: [7, 9],
    tolerancePercent: 25,
    formatRange: (n) => n + ' h'
  };
  let timeValue = $state<number | null>(null);

  function classifyStatus(value: number | null, config: RangeHintConfig): RangeStatus {
    if (value == null || Number.isNaN(value)) return 'default';
    const [min, max] = config.expectedRange;
    if (value >= min && value <= max) return 'success';
    // The buffer scales with the range width, so one percentage means the
    // same "slightly outside" for 7–9 h as for 12,000–13,500 kWh.
    const tolerance = ((max - min) * (config.tolerancePercent ?? 15)) / 100;
    if (value >= min - tolerance && value <= max + tolerance) return 'warning';
    return 'danger';
  }

  function formatHelper(config: RangeHintConfig, status: RangeStatus): string {
    const fmt = config.formatRange ?? ((n: number) => String(n));
    const [min, max] = config.expectedRange;
    const range = `${fmt(min)}${fmt(max)}`;
    if (status === 'success') return `Plausible. Expected: ${range}.`;
    if (status === 'warning') return `Slightly outside. Expected: ${range}.`;
    if (status === 'danger') {
      const help = config.helpOnDanger ? ' ' + config.helpOnDanger : '';
      return `Unusual. Expected: ${range}.${help}`;
    }
    return `Expected: ${range}.`;
  }

  const meterStatus = $derived(classifyStatus(meterValue, meterConfig));
  const meterHelper = $derived(formatHelper(meterConfig, meterStatus));
  const budgetStatus = $derived(classifyStatus(budgetValue, budgetConfig));
  const budgetHelper = $derived(formatHelper(budgetConfig, budgetStatus));
  const timeStatus = $derived(classifyStatus(timeValue, timeConfig));
  const timeHelper = $derived(formatHelper(timeConfig, timeStatus));
</script>

<!-- The form column: centre it in your page's own layout. -->
<div class="w-full max-w-md space-y-6">
  <!-- Plausibility, not validation: nothing gates the entry. intent tints
       the field's border; the message stays in the quiet helper voice, and
       the error prop stays free for hard constraints. -->
  <Input
    type="number"
    label={meterConfig.label}
    bind:value={meterValue}
    intent={meterStatus}
    helper={meterHelper}
    placeholder="e.g. 12750"
  />
  <Input
    type="number"
    label={budgetConfig.label}
    bind:value={budgetValue}
    intent={budgetStatus}
    helper={budgetHelper}
    placeholder="e.g. 950"
  />
  <Input
    type="number"
    label={timeConfig.label}
    bind:value={timeValue}
    intent={timeStatus}
    helper={timeHelper}
    placeholder="e.g. 8"
  />
</div>

Two decisions

Unusual is not invalid

A value far outside the range can still be right: a reading after a meter swap or a one-off purchase lands there legitimately, so the field keeps accepting it and helpOnDanger asks the question that separates a typo from a real outlier. When a limit is hard, set Input's error prop instead: it replaces helper and outranks intent, so a hard constraint takes the field over while it is violated.

The range comes from data, the tolerance from the domain

Derive expectedRange from what the app already knows (last period's value, a trend over the last three); a hand-picked constant drifts as consumption or prices move, and the hint starts flagging normal values. tolerancePercent is a per-field judgment: tighter where entries should be exact, wider for estimates, and never 0, which erases the warning zone and makes every miss read as unusual.