{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-range-input",
  "title": "Number Range Input",
  "description": "Two number boxes that behave as one field: a low end, a high end, and a single verdict over the pair. Reach for it wherever a page asks for \"from X to Y\" in numbers: a price filter on a catalogue, search or marketplace listing page, a stock or quantity-on-hand filter in an admin table, a score, rating or review band, an age range on a form or audience targeting rule, a salary or compensation band on a job posting, the two thresholds on an alert or automation rule, a size, weight or dimension filter, a row-count or amount filter on a report, a latency, duration or response-time window on a dashboard query, a discount or margin band, a year or model-year range, a min/max pair that goes straight into a query string as ?min=&max=. Common asks it answers: \"number range input react\", \"min max input react\", \"react min max number field\", \"price range input react\", \"shadcn price range filter\", \"shadcn min max input\", \"shadcn number range\", \"shadcn range input not slider\", \"min max filter component\", \"from to number input\", \"two number inputs one field\", \"range input with text entry\", \"type exact price range instead of slider\", \"price filter without slider\", \"shadcn slider vs range input\", \"open ended price range\", \"min only price filter\", \"1000 and up filter\", \"empty max means no upper limit\", \"empty number input becomes 0\", \"Number('') returns 0 bug\", \"cleared input treated as zero\", \"min greater than max validation react\", \"swap min and max automatically\", \"should I swap min and max or show an error\", \"validate range on blur not on change\", \"number input validation while typing is annoying\", \"field turns red while typing react\", \"cannot type decimal in controlled number input\", \"leading zero removed from input react\", \"decimal point disappears when typing react\", \"react controlled number input deletes what I type\", \"debounce range filter query\", \"do not fire query on every keystroke\", \"useSearchParams controlled input loses keystroke\", \"router push controlled input resets\", \"type number input discards invalid value\", \"input type number value empty string\", \"scroll wheel changes number input\", \"inputMode decimal\", \"fieldset legend two inputs\", \"aria-describedby two inputs one error\", \"accessible min max input\", \"screen reader range error\", \"aria-invalid which input\", \"thousands separator input parsing\", \"comma decimal separator input\", \"1,5 parsed as 15 bug\", \"数値 範囲 入力 react\", \"最小 最大 入力\", \"価格帯 フィルタ\", \"下限 上限 入力欄\", \"空欄を0にしない\". Official shadcn/ui has no component of this kind, and the measurement is unusually clean: across its whole registry today (63 items listed, 62 fetched, 223,287 bytes of component source; `questionnaire` 404s on both styles) the words `minValue`, `maxValue`, `rangeStart`, `rangeEnd`, `fromValue`, `toValue`, `clampRange` and `lowerBound` return zero hits each, and so does `inputMode`. Its `slider` is the near neighbour and is genuinely close — it renders one thumb per value, so `value={[50, 500]}` does give you two — but it is a thing you drag inside a fixed track: there is no `<input>` to type an exact figure into, and a track cannot express an open end, so \"1000 and up\" is not a range it can hold. What you get: `NumberRangeInput`, the whole field — a `<fieldset>` named by its `<legend>`, the two boxes, the mark between them and one status line that carries either the error or a plain-language summary of the range (\"50 to 500\", \"1,000 and up\", \"Up to 500\"). Nothing is judged while you are typing: narrowing an upper end from 500 to 60 passes through 6, where the range is briefly crossed, so the verdict waits for a pause (`debounceMs`, 300ms) or for focus to leave the field, and `onCommit` fires on that same beat so a live query never goes out for the half-typed figure. `onValueChange` is the immediate one for parents that want per-keystroke state. Crossed ends are reported rather than quietly repaired, because swapping them the moment they cross takes the number you are halfway through typing and puts it in the other box; `crossBehavior=\"swap\"` is there for filter bars that prefer it, waits until focus leaves, and announces that it swapped. An empty box is an open end and not a zero, so a one-sided range is valid and committable, and `requireBoth` turns that off where both really are needed. `bounds` sets the outer limits either end may take, inclusive, and is reported per end against the limit that was broken. `checkRange` and `parseBound` are exported as plain functions so the route handler receiving `?min=&max=` reaches the same verdict the field showed instead of carrying a second copy of the rules; `checkRange` returns issue codes rather than sentences, and every sentence the field can say is replaceable through `messages`. `parseBound` is deliberately stricter than `Number`: `Number(\"\")` is 0, which is how clearing a filter turns into \"zero and up\", and `1e3`, `Infinity` and `0x10` all parse as numbers to it and as nothing to a price box. Group separators are accepted only where a group separator can go — `1,234,567` yes, `1,5` no — because stripping the character wherever it falls reads a comma one-and-a-half as 15, a bound wrong by a factor of ten with nothing on screen to suggest it; `decimalSeparator` says which convention is in force and is never guessed from the runtime locale, since `Intl` on the server and in the browser can disagree and a field that parses differently in the two passes is a hydration mismatch. The boxes are `type=\"text\"` with `inputMode=\"decimal\"`, not `type=\"number\"`: a number input throws away text it cannot parse, so `input.value` is the empty string for `12e` and the reader's mistake vanishes instead of being explained, it steps its value when a scroll gesture passes over it while focused, and it refuses the half-typed `1.` this field has to accept. The status is attached to both inputs with `aria-describedby`, so the error reaches a screen reader from whichever box the reader is in, and which box is marked `aria-invalid` follows the problem's scope — a bad number marks that box, a crossed pair marks both, since neither number is wrong on its own. Tabbing between the two boxes does not settle the field, because that is movement inside it. Distinct from the neighbours: `number-input` is a single value with stepper buttons, `date-range-preset` is a range of dates chosen from named spans, `currency-input` and `duration-input` are single formatted values, and `filter-chips` shows and clears the filters a field like this one produces.",
  "files": [
    {
      "path": "registry/ui/number-range-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The two ends of a range, as numbers. `null` means that end is open.\n *\n * **`null` is not `0`.** \"1000 and up\" is a range with no upper end, and the only honest way\n * to say that is a null — a `0` there is a real bound that happens to be zero, which is what\n * a temperature or a profit filter actually means by it. Every function in this file keeps\n * the two apart, and the component never turns an empty field into a zero.\n */\nexport interface NumberRange {\n  min: number | null\n  max: number | null\n}\n\n/** The two ends as they are written in the fields, which is what the component actually stores. */\nexport interface NumberRangeText {\n  min: string\n  max: string\n}\n\n/**\n * What is wrong, where, and in codes rather than sentences.\n *\n * `scope` is the half of the field the problem belongs to, and it is the thing a screen\n * reader gets wrong when a component does not track it: `\"pair\"` means neither number is\n * wrong on its own and it is their order that is the problem, so both inputs are marked\n * invalid. `\"min\"` or `\"max\"` marks one input and leaves the other alone.\n *\n * No message text here on purpose. This keeps the checking usable from a route handler or a\n * test with no opinion about language, and leaves the wording to `messages` on the component.\n */\nexport interface RangeIssue {\n  scope: \"min\" | \"max\" | \"pair\"\n  code: \"not-a-number\" | \"below-allowed\" | \"above-allowed\" | \"missing\" | \"crossed\"\n  /** The outer limit that was broken, for `below-allowed` and `above-allowed`. */\n  allowed?: number\n}\n\n/** The answer to \"is this range usable, and if not what is wrong with it\". */\nexport interface RangeVerdict {\n  /** The parsed ends. Unparseable text yields `null`, the same as an empty field. */\n  value: NumberRange\n  /** The text the verdict describes, so a caller can tell a stale verdict from a current one. */\n  text: NumberRangeText\n  /** Highest priority first; `issues[0]` is the one worth putting on screen. */\n  issues: RangeIssue[]\n  ok: boolean\n}\n\nexport interface NumberRangeBounds {\n  /** The smallest value either end may take — the catalogue's floor, not the chosen range. */\n  min?: number\n  /** The largest value either end may take. */\n  max?: number\n}\n\n/**\n * Every sentence this component can say, so all of them can be translated.\n *\n * `{min}`, `{max}` and `{allowed}` are substituted. The two numbers are substituted **as they\n * were typed**, never reformatted — see `summarise` below for why that matters.\n */\nexport interface NumberRangeMessages {\n  notANumber: string\n  belowAllowed: string\n  aboveAllowed: string\n  missing: string\n  crossed: string\n  swapped: string\n  /** Both ends set. */\n  between: string\n  /** Lower end only. */\n  atLeast: string\n  /** Upper end only. */\n  atMost: string\n}\n\nconst DEFAULT_MESSAGES: NumberRangeMessages = {\n  notANumber: \"Enter a number.\",\n  belowAllowed: \"Enter {allowed} or more.\",\n  aboveAllowed: \"Enter {allowed} or less.\",\n  missing: \"Enter both ends of the range.\",\n  crossed: \"The first number must not be greater than the second.\",\n  swapped: \"The two numbers were swapped.\",\n  between: \"{min} to {max}\",\n  atLeast: \"{min} and up\",\n  atMost: \"Up to {max}\",\n}\n\n/** `{name}` substitution. Deliberately not a template literal so `messages` can be data. */\nfunction fill(template: string, vars: Record<string, string>) {\n  return template.replace(/\\{(\\w+)\\}/g, (whole, key) =>\n    key in vars ? vars[key] : whole\n  )\n}\n\n/**\n * Reads one field's text as a number.\n *\n * Returns `null` for an empty field and `NaN` for text that is not a number, which are two\n * different facts: the first is a legitimate open end and the second is a mistake to report.\n * Collapsing them — which is what `Number(\"\")` does, since it answers `0` — is how a filter\n * ends up asking for \"zero and up\" because somebody cleared the box.\n *\n * `decimalSeparator` decides the grammar, and it has to be told rather than guessed. With `\".\"`\n * the grouping character is `,`, so `1,234.5` is 1234.5; with `\",\"` it is the other way round and\n * `1.234,5` is the same number. **Guessing would be dangerous rather than merely wrong**: read\n * `1,5` as grouped and it becomes 15, a price filter off by a factor of ten with nothing on\n * screen to suggest it. Nothing here infers the separator from the runtime locale either,\n * because `Intl` on the server and `Intl` in the browser can disagree and a field that parses\n * differently in the two passes is a hydration mismatch. Pass it down from wherever the rest of\n * the page gets its locale.\n *\n * The grammar is strict on purpose. `1.` and `.5` pass, because they are what a half-typed\n * decimal looks like and a field that rejects them cannot be typed into. `1e3`, `Infinity`,\n * `0x10` and a bare `-` do not, because `Number` accepts all four and none of them is a figure\n * anyone meant to put in a price box.\n *\n * Group separators are only accepted **where a group separator can go** — `1,234,567` yes,\n * `1,5` no. Stripping the character wherever it appears is the version of this that looks\n * finished and is the dangerous one: in a dot-decimal field a reader who writes `1,5` meaning\n * one and a half gets 15, a bound off by a factor of ten with nothing on screen to suggest it.\n * Refusing it costs them a correction; accepting it costs them the wrong answer silently.\n */\nexport function parseBound(text: string, decimalSeparator: \".\" | \",\" = \".\"): number | null {\n  const group = decimalSeparator === \".\" ? \",\" : \".\"\n  // Surrounding whitespace is noise from a paste and carries no meaning. `trim` covers the\n  // no-break and narrow no-break spaces too, both being Unicode space separators.\n  const trimmed = text.trim()\n  if (trimmed === \"\") return null\n  // What is left of a space is a *group* separator — it is how fr-FR writes 1 234,5, and Intl\n  // emits the narrow no-break one for it — so it is rewritten to the group character and judged\n  // by position with the rest. Letting it through unchecked instead is the same factor-of-ten\n  // mistake in a different costume: \"1 2 3 4\" would read as 1234.\n  let bare = \"\"\n  for (const char of trimmed) {\n    const isGroupSpace =\n      char === \" \" || char === \"\\u00a0\" || char === \"\\u202f\" || char === \"_\"\n    bare += isGroupSpace ? group : char\n  }\n\n  const sign = bare[0] === \"+\" || bare[0] === \"-\" ? bare[0] : \"\"\n  const digits = sign ? bare.slice(1) : bare\n\n  // Split on the decimal separator, which can appear at most once. More than one means the text\n  // is using the other convention's grouping, and there is no way to tell that from a typo.\n  const parts = digits.split(decimalSeparator)\n  if (parts.length > 2) return Number.NaN\n  const whole = parts[0]\n  const fraction = parts.length === 2 ? parts[1] : \"\"\n\n  // Nothing is grouped after the decimal point in any convention, so a separator there is junk.\n  // The `Number` call at the end would refuse it too, since the fraction is concatenated as it\n  // stands — this says the rule out loud, and keeps it true if that construction ever changes.\n  if (!/^\\d*$/.test(fraction)) return Number.NaN\n  // Either ungrouped digits, or digits in groups of three. An empty whole part is allowed only\n  // for the \".5\" shorthand, which is why the fraction has to carry something in that case.\n  const grouped = new RegExp(`^\\\\d{1,3}(${group === \".\" ? \"\\\\.\" : \",\"}\\\\d{3})+$`)\n  if (whole === \"\") {\n    if (fraction === \"\") return Number.NaN\n  } else if (!/^\\d+$/.test(whole) && !grouped.test(whole)) {\n    return Number.NaN\n  }\n\n  const value = Number(\n    `${sign}${whole.split(group).join(\"\")}${fraction === \"\" ? \"\" : `.${fraction}`}`\n  )\n  return Number.isFinite(value) ? value : Number.NaN\n}\n\n/**\n * Checks a pair of typed ends and reports everything wrong with them, worst first.\n *\n * Exported as a plain function because the same question is asked away from the keyboard: the\n * route handler that receives `?min=&max=` has to reach the same verdict the field showed, and a\n * second implementation of the rules there is a second set of rules.\n *\n * **Equal ends are a range, not a mistake.** `min === max` is \"exactly 500\" and passes; only\n * `min > max` is crossed. The `>=` that looks equivalent while writing this rejects every\n * exact-value filter.\n *\n * The order of `issues` is the priority order, and it puts each end's own problem ahead of the\n * pair's: text that is not a number cannot be compared to anything, so saying \"the first number\n * must not be greater than the second\" about it would be noise on top of a mistake.\n */\nexport function checkRange(\n  text: NumberRangeText,\n  {\n    bounds,\n    requireBoth = false,\n    decimalSeparator = \".\",\n  }: {\n    bounds?: NumberRangeBounds\n    requireBoth?: boolean\n    decimalSeparator?: \".\" | \",\"\n  } = {}\n): RangeVerdict {\n  const issues: RangeIssue[] = []\n  const parsed: NumberRange = { min: null, max: null }\n  const missing: Array<\"min\" | \"max\"> = []\n\n  for (const side of [\"min\", \"max\"] as const) {\n    const raw = parseBound(text[side], decimalSeparator)\n    if (raw === null) {\n      missing.push(side)\n      continue\n    }\n    if (Number.isNaN(raw)) {\n      issues.push({ scope: side, code: \"not-a-number\" })\n      continue\n    }\n    parsed[side] = raw\n    if (bounds?.min !== undefined && raw < bounds.min) {\n      issues.push({ scope: side, code: \"below-allowed\", allowed: bounds.min })\n    } else if (bounds?.max !== undefined && raw > bounds.max) {\n      issues.push({ scope: side, code: \"above-allowed\", allowed: bounds.max })\n    }\n  }\n\n  if (requireBoth) {\n    for (const side of missing) issues.push({ scope: side, code: \"missing\" })\n  }\n\n  if (parsed.min !== null && parsed.max !== null && parsed.min > parsed.max) {\n    issues.push({ scope: \"pair\", code: \"crossed\" })\n  }\n\n  return { value: parsed, text, issues, ok: issues.length === 0 }\n}\n\n/**\n * The one-line description of a settled range, built from the typed text.\n *\n * From the text rather than from `value` so the numbers on screen and the numbers read aloud are\n * the same characters. Round-tripping through `Number` would rewrite `1,000` as `1000` and\n * `07` as `7`, and reformatting with `Intl` would need a locale, which is the hydration mismatch\n * described on `parseBound`. Returns `\"\"` for a field with nothing in it: a range that has not\n * been set yet has nothing to announce, and a status line that always says something is a status\n * line people stop reading.\n */\nexport function summarise(\n  text: NumberRangeText,\n  messages: NumberRangeMessages = DEFAULT_MESSAGES\n): string {\n  const min = text.min.trim()\n  const max = text.max.trim()\n  if (min && max) return fill(messages.between, { min, max })\n  if (min) return fill(messages.atLeast, { min })\n  if (max) return fill(messages.atMost, { max })\n  return \"\"\n}\n\n/** The sentence for one issue. */\nfunction describe(issue: RangeIssue, messages: NumberRangeMessages): string {\n  const allowed = issue.allowed === undefined ? \"\" : String(issue.allowed)\n  switch (issue.code) {\n    case \"not-a-number\":\n      return messages.notANumber\n    case \"below-allowed\":\n      return fill(messages.belowAllowed, { allowed })\n    case \"above-allowed\":\n      return fill(messages.aboveAllowed, { allowed })\n    case \"missing\":\n      return messages.missing\n    case \"crossed\":\n      return messages.crossed\n  }\n}\n\nconst EMPTY: NumberRangeText = { min: \"\", max: \"\" }\n\n/**\n * Whether two pairs of text say the same thing.\n *\n * By content, never by object identity. The text is a value, and a value object is rebuilt on\n * every keystroke — type an `x` and delete it again and the pair is a different object carrying\n * the same two strings. Compared by identity, that reads as a change: the field goes back to\n * showing no verdict about text it has already judged, and the pending query fires a second time\n * for a range nobody altered.\n */\nfunction sameText(a: NumberRangeText, b: NumberRangeText) {\n  return a.min === b.min && a.max === b.max\n}\n\n/** The text a `NumberRange` prop would be written as, used only when it replaces what was typed. */\nfunction textFor(value: number | null | undefined): string {\n  return value === null || value === undefined ? \"\" : String(value)\n}\n\ntype InputProps = Omit<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"min\" | \"max\" | \"type\" | \"name\" | \"aria-invalid\"\n>\n\ninterface NumberRangeInputProps extends InputProps {\n  /**\n   * The field's name, as a `<legend>`. Required, because two number boxes side by side are\n   * meaningless without it — \"Minimum\" and \"Maximum\" alone do not say minimum of what.\n   */\n  label: string\n  /** Keep the legend for screen readers but take it off the screen. */\n  hideLabel?: boolean\n  /** The chosen range. Pass this with `onValueChange` for a controlled field. */\n  value?: NumberRange\n  /** The starting range when the field is uncontrolled. */\n  defaultValue?: NumberRange\n  /** Every keystroke, immediately, with both the parsed ends and the raw text. */\n  onValueChange?: (value: NumberRange, text: NumberRangeText) => void\n  /**\n   * Once the typing has stopped (`debounceMs`) or focus has left the field — the moment to run\n   * the query. Gate on `verdict.ok`; a crossed or half-typed range settles too.\n   */\n  onCommit?: (verdict: RangeVerdict) => void\n  /** The outer limits either end may take. Not the chosen range — see `NumberRangeBounds`. */\n  bounds?: NumberRangeBounds\n  /** Treat a one-sided range as incomplete. Off by default, because one-sided ranges are real. */\n  requireBoth?: boolean\n  /**\n   * What to do when the ends are the wrong way round. `\"flag\"` says so and changes nothing.\n   * `\"swap\"` puts them right, but only once focus has left the field, and announces that it did.\n   */\n  crossBehavior?: \"flag\" | \"swap\"\n  /** `\".\"` or `\",\"`. Must be told, not guessed — see `parseBound`. */\n  decimalSeparator?: \".\" | \",\"\n  /** How long the typing has to stop before the range is judged. */\n  debounceMs?: number\n  /** Accessible names for the two inputs. */\n  minLabel?: string\n  maxLabel?: string\n  minPlaceholder?: string\n  maxPlaceholder?: string\n  /** The mark between the two inputs. Decorative, so it is hidden from screen readers. */\n  separator?: React.ReactNode\n  /** `name` for each input, when the field is inside a form that posts itself. */\n  nameMin?: string\n  nameMax?: string\n  /** Show the status line that carries the error and the summary. */\n  showStatus?: boolean\n  /** Overrides for any of the sentences. */\n  messages?: Partial<NumberRangeMessages>\n  /** On the `<fieldset>`. The inputs take `inputClassName`; other props reach both inputs. */\n  className?: string\n  inputClassName?: string\n}\n\n/**\n * Two number fields that behave as one: a low end, a high end, and one verdict over the pair.\n *\n * For every place a page asks for \"from X to Y\" in numbers — a price filter on a catalogue or\n * search page, stock or quantity on hand, a score or rating band, an age range, a salary band on\n * a job posting, a threshold pair on an alert rule, a size or weight filter, a row-count or\n * amount filter on a report, a latency or duration window on a dashboard query, a discount or\n * margin band, a year range.\n *\n * **Nothing is judged while you are typing.** This is the whole design, and the obvious version\n * of the component does not survive it. Replace `50` with `500` in the lower box and the field\n * passes through `5`, `50`, `500`: at `5` the lower end is below where it started and at every\n * keystroke the pair is briefly inconsistent. A field that validates on change is therefore red\n * for most of the time anyone is using it, and one that *corrects* on change is worse — it moves\n * the digits out from under the caret. So the verdict waits for a pause (`debounceMs`, 300ms) or\n * for focus to leave, shows nothing in the meantime rather than a stale answer, and `onCommit`\n * fires on the same beat so a live query does not go out for the `5` on the way to `500`.\n *\n * **Crossed ends are reported, not quietly fixed.** Swapping them the moment they cross takes the\n * number you are halfway through typing and puts it in the other box, which reads as the field\n * eating your input. The default says so and changes nothing; `crossBehavior=\"swap\"` is available\n * for filter bars that prefer it and it waits for focus to leave the whole field, then says that\n * it swapped — a correction nobody is told about is a correction a screen reader user cannot see.\n *\n * **An empty end is open, not zero.** \"1000 and up\" is a complete, committable range, so an\n * empty box is `null` rather than `0` and the field is valid with one end set. `requireBoth`\n * turns that off where both really are needed.\n *\n * **The two boxes are one field.** They sit in a `<fieldset>` with the field's name as its\n * `<legend>`, and the status line is attached to *both* inputs with `aria-describedby`, so the\n * error reaches a screen reader from whichever box the reader is in — attach it to one and half\n * the field is silent about what is wrong. Which box is marked `aria-invalid` follows the\n * problem's scope: a bad number marks that box, a crossed pair marks both, because neither\n * number is wrong on its own.\n *\n * **The text belongs to the field, even when the range does not.** `value` makes this controlled\n * over the two *numbers*; the characters stay here, because no number can hold `07`, `1.` or a\n * half-typed `abc` that still has to be shown back and explained. `value` replaces the text\n * whenever `value` changes — a preset button, a reset — and a parent that answers a render late,\n * as anything storing the range in the URL does, is left to catch up rather than treated as\n * having refused the keystroke.\n *\n * The inputs are `type=\"text\"` with `inputMode=\"decimal\"`, not `type=\"number\"`. A number input\n * **throws away what it cannot parse** — type `12e` and `input.value` is the empty string, so the\n * component cannot show it back, cannot say what is wrong with it, and the reader watches their\n * text disappear. It also steps the value when a scroll gesture passes over it while focused,\n * and it refuses the half-typed `1.` this field has to accept. `inputMode` still brings up the\n * numeric keypad on a phone.\n *\n * Distinct from the neighbours: `number-input` is a single value with stepper buttons,\n * `date-range-preset` is a range of dates chosen from named spans, and official shadcn/ui's\n * `slider` renders a thumb per value so it can hold two — but it is a thing you drag inside a\n * fixed track, which cannot express an open end and cannot be typed an exact figure into.\n */\nexport function NumberRangeInput({\n  label,\n  hideLabel = false,\n  value,\n  defaultValue,\n  onValueChange,\n  onCommit,\n  bounds,\n  requireBoth = false,\n  crossBehavior = \"flag\",\n  decimalSeparator = \".\",\n  debounceMs = 300,\n  minLabel = \"Minimum\",\n  maxLabel = \"Maximum\",\n  minPlaceholder,\n  maxPlaceholder,\n  separator = \"–\",\n  nameMin,\n  nameMax,\n  showStatus = true,\n  messages: messageOverrides,\n  className,\n  inputClassName,\n  disabled,\n  readOnly,\n  // A phone keyboard that autocapitalises and autocorrects can rewrite a figure between the\n  // keypress and the change event, and a spellchecker underlines every number it sees. All four\n  // are defaults rather than fixed, for the rare field that wants the browser's help.\n  spellCheck = false,\n  autoCapitalize = \"off\",\n  autoCorrect = \"off\",\n  autoComplete = \"off\",\n  id,\n  onBlur,\n  onKeyDown,\n  \"aria-describedby\": ariaDescribedby,\n  ...props\n}: NumberRangeInputProps) {\n  const messages = React.useMemo(\n    () => ({ ...DEFAULT_MESSAGES, ...messageOverrides }),\n    [messageOverrides]\n  )\n  const isControlled = value !== undefined\n\n  // Pulled apart into numbers because `bounds` is almost always written inline — `bounds={{ min:\n  // 0, max: 10000 }}` — which is a new object on every render of the parent. Anything that\n  // depends on that object's identity restarts on every one of those renders, and the debounce\n  // below is the thing that must not: restarted often enough it never elapses, and the field\n  // stops ever reaching a verdict. Two numbers cannot do that.\n  const boundsMin = bounds?.min\n  const boundsMax = bounds?.max\n  const judge = (next: NumberRangeText) =>\n    checkRange(next, {\n      bounds: { min: boundsMin, max: boundsMax },\n      requireBoth,\n      decimalSeparator,\n    })\n\n  const seed = value ?? defaultValue\n  const seedText: NumberRangeText = seed\n    ? { min: textFor(seed.min), max: textFor(seed.max) }\n    : EMPTY\n\n  const [text, setText] = React.useState<NumberRangeText>(seedText)\n\n  // The settled verdict, carrying the text it was reached about. Keeping the text alongside is\n  // what lets the field say nothing at all mid-edit: comparing it to the current text separates\n  // \"no opinion yet\" from \"valid\", which a bare verdict cannot express. Seeded synchronously so a\n  // field that opens with a range already in it is right on the first paint.\n  const [settled, setSettled] = React.useState<RangeVerdict>(() => judge(seedText))\n  const [swapped, setSwapped] = React.useState(false)\n\n  // Read inside the debounce timer and the blur handler, which both run after the render that\n  // scheduled them — reading `text` through this ref keeps them from settling a verdict about\n  // text that has already been replaced.\n  const textRef = React.useRef(text)\n  textRef.current = text\n  const settledTextRef = React.useRef(settled.text)\n  const fieldsetRef = React.useRef<HTMLFieldSetElement | null>(null)\n\n\n  // The last `value` this field was given, so a *change* to it can be told apart from merely\n  // being rendered again.\n  const lastValueRef = React.useRef<NumberRange | null>(\n    value ? { min: value.min ?? null, max: value.max ?? null } : null\n  )\n\n  // Let a controlled parent replace the text — when, and only when, the range it is holding\n  // actually changes.\n  //\n  // Two different mistakes are being avoided here, and avoiding one is what causes the other.\n  // Writing `String(value.min)` back on every render is the first: `07` becomes `7` and `1.`\n  // becomes `1`, both under the caret, which is what makes the obvious controlled number field\n  // impossible to type a decimal into. Comparing the parsed text against the incoming number\n  // fixes that much — the text survives while it still means the same number.\n  //\n  // The second is subtler and only shows up with a parent that answers late. A filter's state\n  // usually lives in the URL, and `useSearchParams` with a `router.push` does not come back in\n  // the same render: for one pass the field is holding `07` while `value.min` is still the old\n  // `null`. Reconciling on that pass reads \"the parent disagrees\" and wipes the keystroke — and\n  // since every keystroke has such a pass, the field cannot be typed in at all. The two cases\n  // are indistinguishable from the value alone, so the trigger is the prop *changing* rather\n  // than the prop's content, and a parent that is simply behind is left to catch up.\n  //\n  // The contract that follows, and it is the one documented on the component: the text belongs to\n  // this field, and `value` replaces it whenever `value` changes. A parent that ignores\n  // `onValueChange` altogether is not a controlled field refusing a keystroke — it is a field\n  // nobody is controlling.\n  React.useEffect(() => {\n    if (!isControlled || !value) return\n    const incoming: NumberRange = { min: value.min ?? null, max: value.max ?? null }\n    const last = lastValueRef.current\n    if (last && last.min === incoming.min && last.max === incoming.max) return\n    lastValueRef.current = incoming\n    setText((previous) => {\n      let next = previous\n      for (const side of [\"min\", \"max\"] as const) {\n        const parsed = parseBound(previous[side], decimalSeparator)\n        const says = parsed === null || Number.isNaN(parsed) ? null : parsed\n        if (says !== incoming[side]) {\n          next = next === previous ? { ...previous } : next\n          next[side] = textFor(incoming[side])\n        }\n      }\n      return next\n    })\n  }, [isControlled, value?.min, value?.max, decimalSeparator])\n\n  const settle = (next: NumberRangeText) => {\n    const verdict = judge(next)\n    settledTextRef.current = next\n    setSettled(verdict)\n    onCommit?.(verdict)\n    return verdict\n  }\n  // Reached from inside the timer, which was scheduled by an earlier render and must not call\n  // that render's copy of `settle` — its `onCommit` and its bounds may both be a prop change out\n  // of date by the time it fires.\n  const settleRef = React.useRef(settle)\n  settleRef.current = settle\n\n  React.useEffect(() => {\n    if (sameText(text, settled.text)) return\n    const timer = setTimeout(() => {\n      // Checked again in the timer because the text may have come back to what is already\n      // settled — a character typed and deleted again — in which case the verdict on screen is\n      // the right one and firing `onCommit` for it would send the same query twice.\n      if (sameText(settledTextRef.current, textRef.current)) return\n      settleRef.current(textRef.current)\n    }, debounceMs)\n    return () => clearTimeout(timer)\n    // The two pairs by their strings rather than by their objects, so an unchanged field does\n    // not reschedule the timer on every render of whatever is above it.\n  }, [text.min, text.max, settled.text.min, settled.text.max, debounceMs])\n\n  const commit = (next: NumberRangeText) => {\n    setSwapped(false)\n    // Set even when the field is controlled, which is the part that looks wrong and is not. A\n    // number cannot hold `07`, `1.` or `abc`, so a controlled parent's `NumberRange` cannot be\n    // the source of what is on screen without eating all three. The text is this component's own\n    // state either way; the parent owns the *numbers*, and the effect above puts the text back in\n    // line whenever the parent's answer disagrees with what the text says — including when it\n    // refuses the change outright, which is how a controlled field says no.\n    setText(next)\n    onValueChange?.(judge(next).value, next)\n  }\n\n  const handleChange = (side: \"min\" | \"max\", raw: string) => {\n    commit({ ...text, [side]: raw })\n  }\n\n  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {\n    onBlur?.(event)\n    // Moving from one box to the other is movement *inside* the field, so it must not settle:\n    // tabbing from the lower end to fill in the upper one would otherwise flash \"the first number\n    // must not be greater than the second\" about a range that is one keystroke from being right.\n    const to = event.relatedTarget as Node | null\n    if (to && fieldsetRef.current?.contains(to)) return\n\n    let next = textRef.current\n    const verdict = judge(next)\n    if (\n      crossBehavior === \"swap\" &&\n      !readOnly &&\n      !disabled &&\n      verdict.issues.some((issue) => issue.code === \"crossed\")\n    ) {\n      next = { min: next.max, max: next.min }\n      setText(next)\n      setSwapped(true)\n      onValueChange?.(judge(next).value, next)\n    }\n    settle(next)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event)\n    if (event.defaultPrevented) return\n    // Enter settles without waiting for the pause, because someone who presses it has finished.\n    // Not prevented: this field is usually in a form, and swallowing Enter would stop it\n    // submitting.\n    if (event.key === \"Enter\") settle(textRef.current)\n  }\n\n  const reactId = React.useId()\n  const fieldId = id ?? `${reactId}-number-range`\n  const minId = `${fieldId}-min`\n  const maxId = `${fieldId}-max`\n  const statusId = `${fieldId}-status`\n\n  const pending = !sameText(text, settled.text)\n  // One sentence at a time on screen — the highest priority one — because a status line that\n  // lists everything at once is a status line nobody finishes reading.\n  const issue = pending ? null : (settled.issues[0] ?? null)\n  const statusText = [\n    // Always first when it applies, including on top of an error: a range whose ends were moved\n    // and which is *still* wrong needs to say both, or the reader is left wondering why the\n    // number they typed is in the other box.\n    swapped ? messages.swapped : \"\",\n    issue ? describe(issue, messages) : pending ? \"\" : summarise(settled.text, messages),\n  ]\n    .filter(Boolean)\n    .join(\" \")\n\n  // Every issue, not just the one on screen. Both boxes can be wrong at once — two unparseable\n  // figures is the ordinary way that happens — and marking only the box whose message won the\n  // status line leaves a screen reader telling the reader that the other one is fine.\n  const invalid = (side: \"min\" | \"max\") =>\n    !pending && settled.issues.some((i) => i.scope === side || i.scope === \"pair\")\n\n  // Attached to both inputs, not just the one at fault: the reader may be in either box when the\n  // error appears, and an input that describes nothing tells them nothing is wrong.\n  const describedBy =\n    [ariaDescribedby, showStatus ? statusId : null].filter(Boolean).join(\" \") || undefined\n\n  const field = (side: \"min\" | \"max\") => (\n    <input\n      {...props}\n      id={side === \"min\" ? minId : maxId}\n      name={side === \"min\" ? nameMin : nameMax}\n      // See the note on the component: type=\"number\" discards text it cannot parse, so the\n      // reader's mistake would vanish instead of being explained.\n      type=\"text\"\n      inputMode=\"decimal\"\n      spellCheck={spellCheck}\n      autoCapitalize={autoCapitalize}\n      autoCorrect={autoCorrect}\n      autoComplete={autoComplete}\n      value={text[side]}\n      placeholder={side === \"min\" ? minPlaceholder : maxPlaceholder}\n      aria-label={side === \"min\" ? minLabel : maxLabel}\n      aria-invalid={invalid(side) ? true : undefined}\n      aria-describedby={describedBy}\n      disabled={disabled}\n      readOnly={readOnly}\n      onChange={(event) => handleChange(side, event.target.value)}\n      onBlur={handleBlur}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm tabular-nums shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n        invalid(side) && \"border-destructive focus-visible:ring-destructive\",\n        inputClassName\n      )}\n    />\n  )\n\n  return (\n    <fieldset\n      ref={fieldsetRef}\n      className={cn(\"min-w-0 space-y-1.5\", className)}\n      disabled={disabled}\n    >\n      <legend className={cn(\"text-sm font-medium leading-none\", hideLabel && \"sr-only\")}>\n        {label}\n      </legend>\n      <div className=\"flex items-center gap-2\">\n        {field(\"min\")}\n        <span aria-hidden=\"true\" className=\"shrink-0 text-sm text-muted-foreground\">\n          {separator}\n        </span>\n        {field(\"max\")}\n      </div>\n      {showStatus && (\n        // Polite, and only ever written to once the typing has stopped, so a screen reader\n        // reports the verdict instead of narrating every half-finished state on the way to it.\n        <p\n          id={statusId}\n          aria-live=\"polite\"\n          className={cn(\n            \"min-h-5 text-xs\",\n            issue ? \"text-destructive\" : \"text-muted-foreground\"\n          )}\n        >\n          {statusText}\n        </p>\n      )}\n    </fieldset>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
