{"name":"quantity-field","type":"registry:component","files":[{"path":"quantity-field.tsx","type":"registry:component","target":"components/ui/quantity-field/quantity-field.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert as CircleAlertIcon } from \"honestui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n  NumberField,\n  NumberFieldDecrement,\n  NumberFieldGroup,\n  NumberFieldIncrement,\n  NumberFieldInput,\n} from \"@/components/ui/number-field\"\nimport {\n  Select,\n  SelectItem,\n  SelectPopup,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\n\nimport type {\n  QuantityFieldChangeDetails,\n  QuantityFieldLabels,\n  QuantityRounding,\n  QuantityUnit,\n  QuantityUnitGroup,\n} from \"./quantity-field-types\"\nimport {\n  applyDisplayChange,\n  defaultQuantityFieldLabels,\n  formatExactQuantity,\n  formatQuantity,\n  getFractionDigits,\n  getNumberSeparators,\n  getRangeError,\n  getUnit,\n  isDisplayExact,\n  parseQuantityText,\n  resolvePaste,\n  toDisplayNumber,\n} from \"./quantity-field-utils\"\n\nexport type {\n  QuantityFieldChangeDetails,\n  QuantityFieldLabels,\n  QuantityRangeError,\n  QuantityRounding,\n  QuantityUnit,\n  QuantityUnitGroup,\n} from \"./quantity-field-types\"\nexport {\n  convertQuantity,\n  defaultQuantityFieldLabels,\n  durationUnits,\n  storageUnits,\n} from \"./quantity-field-utils\"\n\ntype NumberChangeHandler = NonNullable<React.ComponentProps<typeof NumberField>[\"onValueChange\"]>\n\n/** Changes that move the value by a step. Everything else replaces the number. */\nconst STEP_REASONS: ReadonlySet<string> = new Set([\n  \"keyboard\",\n  \"increment-press\",\n  \"decrement-press\",\n  \"wheel\",\n  \"scrub\",\n])\n\nconst PASTED_TEXT_LIMIT = 40\n\nconst smallText =\n  \"[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\"\n\ntype QuantityFieldContextValue = {\n  inputId: string\n  labelId: string\n  statusId: string\n  unitNameId: string\n  unitLabelId: string\n  /** Changes when the number field must be rebuilt to show a pasted value. */\n  revision: number\n  units: readonly QuantityUnit[]\n  unit: QuantityUnit\n  value: number | null\n  shownNumber: number | null\n  fractionDigits: number\n  min: number | undefined\n  max: number | undefined\n  step: number\n  locale: Intl.LocalesArgument | undefined\n  disabled: boolean\n  readOnly: boolean\n  required: boolean\n  labels: QuantityFieldLabels\n  error: string | null\n  notes: readonly string[]\n  onNumberChange: NumberChangeHandler\n  onInputBlur: () => void\n  onPaste: (event: React.ClipboardEvent<HTMLInputElement>) => void\n  onUnitChange: (key: string | null) => void\n}\n\nconst QuantityFieldContext = React.createContext<QuantityFieldContextValue | null>(null)\n\nfunction useQuantityFieldContext(part: string) {\n  const context = React.useContext(QuantityFieldContext)\n  if (!context) {\n    throw new Error(`${part} must be used within a QuantityField.`)\n  }\n  return context\n}\n\nfunction useControllableState<T>(controlled: T | undefined, initial: T) {\n  const [internal, setInternal] = React.useState(initial)\n  return [controlled === undefined ? internal : controlled, setInternal] as const\n}\n\nfunction shorten(text: string) {\n  const trimmed = text.trim()\n  return trimmed.length > PASTED_TEXT_LIMIT ? `${trimmed.slice(0, PASTED_TEXT_LIMIT)}…` : trimmed\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                       */\n/* -------------------------------------------------------------------------- */\n\nexport type QuantityFieldProps<TKey extends string = string> = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  /** The units this quantity can be measured in, such as `durationUnits`. */\n  unitGroup: QuantityUnitGroup<TKey>\n  /** The units offered in the menu, in menu order. Defaults to every unit in the group. */\n  units?: readonly TKey[] | undefined\n  /** The unit `value`, `min`, and `max` are counted in. Defaults to the group's base unit. */\n  valueUnit?: TKey | undefined\n  /** The quantity, counted in `valueUnit`. `null` is an empty field. */\n  value?: number | null | undefined\n  defaultValue?: number | null | undefined\n  onValueChange?:\n    | ((value: number | null, details: QuantityFieldChangeDetails<TKey>) => void)\n    | undefined\n  /** The unit shown beside the number. Changing it never changes `value`. */\n  unit?: TKey | undefined\n  defaultUnit?: TKey | undefined\n  onUnitChange?: ((unit: TKey) => void) | undefined\n  /** The smallest allowed value, in `valueUnit`. Defaults to 0. */\n  min?: number | undefined\n  /** The largest allowed value, in `valueUnit`. */\n  max?: number | undefined\n  rounding?: QuantityRounding | undefined\n  /** How far a stepper or arrow key moves the number, in the unit shown. Defaults to 1. */\n  step?: number | undefined\n  locale?: Intl.LocalesArgument | undefined\n  /** Submits `value` with a form, counted in `valueUnit`. */\n  name?: string | undefined\n  disabled?: boolean | undefined\n  readOnly?: boolean | undefined\n  required?: boolean | undefined\n  labels?: Partial<QuantityFieldLabels> | undefined\n}\n\n/**\n * Holds one quantity as a number of `valueUnit` and shows it in whichever\n * unit is selected. The unit is a view: switching it converts the number on\n * screen and leaves the stored value alone.\n */\nfunction QuantityField<TKey extends string = string>({\n  unitGroup,\n  units: unitKeys,\n  valueUnit: valueUnitKey,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  unit: unitProp,\n  defaultUnit,\n  onUnitChange,\n  min = 0,\n  max,\n  rounding,\n  step = 1,\n  locale,\n  name,\n  id,\n  disabled = false,\n  readOnly = false,\n  required = false,\n  labels: labelsProp,\n  className,\n  children,\n  ...props\n}: QuantityFieldProps<TKey>) {\n  const generatedId = React.useId()\n  const inputId = id ?? `${generatedId}-input`\n\n  const labels = React.useMemo(\n    () => ({ ...defaultQuantityFieldLabels, ...labelsProp }),\n    [labelsProp]\n  )\n  const separators = React.useMemo(() => getNumberSeparators(locale), [locale])\n\n  const valueUnit = getUnit(unitGroup, valueUnitKey ?? unitGroup.base)\n  const units = (unitKeys ?? unitGroup.units.map((candidate) => candidate.key)).map((key) =>\n    getUnit(unitGroup, key)\n  )\n  const fallbackUnit = units.find((candidate) => candidate.key === valueUnit.key) ?? units[0]\n\n  const [value, setValue] = useControllableState(valueProp, defaultValue)\n  const [unitKey, setUnitKey] = useControllableState(unitProp, defaultUnit ?? fallbackUnit.key)\n  const unit = units.find((candidate) => candidate.key === unitKey) ?? fallbackUnit\n\n  // While someone is typing, \"1\" on the way to \"10\" is not yet a value to judge against the limits.\n  const [isTyping, setIsTyping] = React.useState(false)\n  const [pasteError, setPasteError] = React.useState<string | null>(null)\n  const [conversionNote, setConversionNote] = React.useState<string | null>(null)\n  const [revision, setRevision] = React.useState(0)\n\n  const shownNumber = value === null ? null : toDisplayNumber(value, unit, valueUnit, rounding)\n\n  const describeExactly = (quantity: number) =>\n    formatExactQuantity(quantity, { preferred: unit, units, valueUnit, rounding, locale })\n\n  const rangeError = getRangeError(value, min, max)\n  let rangeMessage: string | null = null\n  if (rangeError === \"belowMin\") rangeMessage = labels.belowMin(describeExactly(min))\n  if (rangeError === \"aboveMax\" && max !== undefined) {\n    rangeMessage = labels.aboveMax(describeExactly(max))\n  }\n\n  const error = pasteError ?? (isTyping ? null : rangeMessage)\n  const notes: string[] = []\n  if (conversionNote !== null) notes.push(conversionNote)\n  if (value !== null && !isDisplayExact(value, unit, valueUnit, rounding)) {\n    notes.push(labels.rounded(describeExactly(value)))\n  }\n\n  // Blocks native form submission with the same message the status shows.\n  React.useEffect(() => {\n    const input = document.getElementById(inputId)\n    if (input instanceof HTMLInputElement) input.setCustomValidity(rangeMessage ?? \"\")\n  }, [inputId, rangeMessage, revision])\n\n  // A paste rebuilds the number field, so focus has to be put back in the new input.\n  React.useLayoutEffect(() => {\n    if (revision > 0) document.getElementById(inputId)?.focus()\n  }, [inputId, revision])\n\n  const commitValue = (next: number | null, shownIn: QuantityUnit<TKey>) => {\n    if (next === value) return\n    setValue(next)\n    onValueChange?.(next, {\n      unit: shownIn.key,\n      valueUnit: valueUnit.key,\n      rangeError: getRangeError(next, min, max),\n    })\n  }\n\n  const handleNumberChange: NumberChangeHandler = (next, details) => {\n    const nextValue = applyDisplayChange({\n      value,\n      next,\n      isStep: STEP_REASONS.has(details.reason),\n      unit,\n      valueUnit,\n      rounding,\n      min,\n      max,\n    })\n\n    setIsTyping(details.reason === \"input-change\")\n    if (nextValue !== value || details.reason === \"input-change\") {\n      setPasteError(null)\n      setConversionNote(null)\n    }\n    commitValue(nextValue, unit)\n  }\n\n  const handleUnitChange = (key: string | null) => {\n    const nextUnit = units.find((candidate) => candidate.key === key)\n    if (!nextUnit || nextUnit.key === unit.key) return\n\n    // \"90 min is 1.5 h\" is only true when neither side is a rounded view.\n    const isExactBothWays =\n      value !== null &&\n      isDisplayExact(value, unit, valueUnit, rounding) &&\n      isDisplayExact(value, nextUnit, valueUnit, rounding)\n\n    setPasteError(null)\n    setConversionNote(\n      isExactBothWays\n        ? labels.unitChanged(\n            formatQuantity(value, unit, valueUnit, { rounding, locale }),\n            formatQuantity(value, nextUnit, valueUnit, { rounding, locale })\n          )\n        : null\n    )\n    setUnitKey(nextUnit.key)\n    onUnitChange?.(nextUnit.key)\n  }\n\n  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {\n    if (disabled || readOnly) return\n\n    const pasted = event.clipboardData.getData(\"text/plain\")\n    if (pasted.trim() === \"\") return\n\n    // The number field would otherwise drop the unit and keep only the digits it recognizes.\n    event.preventDefault()\n\n    const parsed = parseQuantityText(pasted, unitGroup, separators)\n    if (parsed === null) {\n      setConversionNote(null)\n      setPasteError(\n        labels.unreadablePaste(\n          shorten(pasted),\n          units.map((candidate) => candidate.symbol)\n        )\n      )\n      return\n    }\n\n    const resolved = resolvePaste(parsed, { group: unitGroup, units, unit, valueUnit, rounding })\n\n    setPasteError(null)\n    setIsTyping(false)\n    setConversionNote(\n      resolved.wasConverted\n        ? labels.pasteConverted(\n            shorten(pasted),\n            formatQuantity(resolved.value, resolved.unit, valueUnit, { rounding, locale })\n          )\n        : null\n    )\n    if (resolved.unit.key !== unit.key) {\n      setUnitKey(resolved.unit.key)\n      onUnitChange?.(resolved.unit.key)\n    }\n    commitValue(resolved.value, resolved.unit)\n    // The number field keeps typed text until blur, so it is rebuilt to show the pasted value now.\n    setRevision((current) => current + 1)\n  }\n\n  const context: QuantityFieldContextValue = {\n    inputId,\n    labelId: `${generatedId}-label`,\n    statusId: `${generatedId}-status`,\n    unitNameId: `${generatedId}-unit-name`,\n    unitLabelId: `${generatedId}-unit-label`,\n    revision,\n    units,\n    unit,\n    value,\n    shownNumber,\n    fractionDigits: getFractionDigits(unit, valueUnit, rounding),\n    min,\n    max,\n    step,\n    locale,\n    disabled,\n    readOnly,\n    required,\n    labels,\n    error,\n    notes,\n    onNumberChange: handleNumberChange,\n    onInputBlur: () => setIsTyping(false),\n    onPaste: handlePaste,\n    onUnitChange: handleUnitChange,\n  }\n\n  return (\n    <QuantityFieldContext.Provider value={context}>\n      <div\n        data-slot=\"quantity-field\"\n        data-invalid={error !== null ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\"flex min-w-0 flex-col items-start\", className)}\n        {...props}\n      >\n        {children}\n        {name !== undefined && (\n          <input type=\"hidden\" name={name} value={value ?? \"\"} disabled={disabled} />\n        )}\n      </div>\n    </QuantityFieldContext.Provider>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Label                                                                      */\n/* -------------------------------------------------------------------------- */\n\nfunction QuantityFieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {\n  const { inputId, labelId } = useQuantityFieldContext(\"QuantityFieldLabel\")\n\n  return (\n    <Label\n      id={labelId}\n      htmlFor={inputId}\n      data-slot=\"quantity-field-label\"\n      className={cn(\"mb-[var(--hui-space-2)]\", className)}\n      {...props}\n    />\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Control                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport type QuantityFieldControlProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Shows the decrease and increase buttons. Arrow keys step either way. Defaults to `true`. */\n  steppers?: boolean | undefined\n}\n\n/** Steppers sit inside the shared edge, so they lose their own border and corners. */\nconst stepperClassName =\n  \"size-auto w-9 rounded-none border-0 bg-transparent text-[var(--hui-color-foreground-base-secondary)] not-disabled:hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:outline-offset-[-2px]\"\n\n/** The unit is an attached segment at the end of the control, on the subtle tint. */\nconst unitSegmentClassName =\n  \"shrink-0 border-0 border-s-[0.5px] border-s-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-secondary)]\"\n\n/** The number, its steppers, and the unit. With one unit offered, the unit is text instead of a menu. */\nfunction QuantityFieldControl({ steppers = true, className, ...props }: QuantityFieldControlProps) {\n  const context = useQuantityFieldContext(\"QuantityFieldControl\")\n  const { unit, units, value, min, max, labels } = context\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={context.labelId}\n      data-slot=\"quantity-field-control\"\n      data-invalid={context.error !== null ? \"\" : undefined}\n      data-disabled={context.disabled ? \"\" : undefined}\n      className={cn(\n        // One 36px control: steppers, number, and unit share a single hairline edge.\n        \"flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-field,var(--hui-color-border-base-tertiary))] bg-[var(--hui-color-background-field,var(--hui-color-background-base-primary))] motion-safe:[transition:border-color_var(--hui-duration-fast)_var(--hui-ease-out),box-shadow_var(--hui-duration-fast)_var(--hui-ease-out)]\",\n        \"not-data-disabled:hover:border-[var(--hui-color-border-field-hover,var(--hui-color-border-base-secondary))]\",\n        \"has-[input:focus]:border-[var(--hui-color-border-accent-emphasis)]! has-[input:focus]:shadow-[var(--hui-focus-ring-field)]\",\n        \"data-invalid:border-[var(--hui-color-border-danger-emphasis)]! has-[input:focus]:data-invalid:shadow-[var(--hui-focus-ring-field-danger)]\",\n        \"data-disabled:border-[var(--hui-color-border-base-primary)] data-disabled:bg-[var(--hui-color-background-base-secondary)]\",\n        className\n      )}\n      {...props}\n    >\n      <NumberField\n        key={context.revision}\n        id={context.inputId}\n        value={context.shownNumber}\n        onValueChange={context.onNumberChange}\n        // Quantities that cannot be negative do not accept a minus sign. The real limits are checked against the stored value.\n        min={min !== undefined && min >= 0 ? 0 : undefined}\n        step={context.step}\n        format={{ maximumFractionDigits: context.fractionDigits }}\n        locale={context.locale}\n        disabled={context.disabled}\n        readOnly={context.readOnly}\n        required={context.required}\n        className=\"h-full min-w-0 flex-1 gap-0\"\n      >\n        <NumberFieldGroup className=\"h-full w-full rounded-none has-[input:focus]:shadow-none [&>*]:h-full\">\n          {steppers && (\n            <NumberFieldDecrement\n              aria-label={labels.decrease}\n              className={stepperClassName}\n              disabled={value !== null && min !== undefined && value <= min ? true : undefined}\n            />\n          )}\n          <NumberFieldInput\n            aria-describedby={`${context.unitNameId} ${context.statusId}`}\n            aria-invalid={context.error !== null || undefined}\n            onBlur={context.onInputBlur}\n            onPaste={context.onPaste}\n            className={cn(\n              \"border-0 bg-transparent px-[var(--hui-space-3)] [font-size:var(--hui-font-size-regular)] [font-weight:var(--hui-font-weight-medium)] focus:not-read-only:bg-transparent data-disabled:bg-transparent\",\n              steppers\n                ? \"border-x-[0.5px] border-x-[var(--hui-color-border-base-primary)] focus:border-x-[var(--hui-color-border-base-primary)] aria-invalid:border-x-[var(--hui-color-border-base-primary)] data-invalid:border-x-[var(--hui-color-border-base-primary)]\"\n                : \"text-start\"\n            )}\n          />\n          {steppers && (\n            <NumberFieldIncrement\n              aria-label={labels.increase}\n              className={stepperClassName}\n              disabled={value !== null && max !== undefined && value >= max ? true : undefined}\n            />\n          )}\n        </NumberFieldGroup>\n      </NumberField>\n\n      {/* Read after the number, so a screen reader says \"90, Minutes\" and not a bare 90. */}\n      <span id={context.unitNameId} hidden>\n        {unit.name}\n      </span>\n\n      {units.length === 1 ? (\n        <span\n          data-slot=\"quantity-field-unit\"\n          className={cn(\n            unitSegmentClassName,\n            \"flex min-w-16 items-center px-[var(--hui-space-4)] text-[var(--hui-color-foreground-base-secondary)]\",\n            smallText,\n            \"[font-size:var(--hui-font-size-control,13px)]\"\n          )}\n        >\n          {unit.symbol}\n        </span>\n      ) : (\n        <>\n          <span id={context.unitLabelId} hidden>\n            {labels.unit}\n          </span>\n          <Select\n            value={unit.key}\n            onValueChange={context.onUnitChange}\n            disabled={context.disabled}\n            readOnly={context.readOnly}\n          >\n            <SelectTrigger\n              size=\"sm\"\n              variant=\"text\"\n              aria-labelledby={`${context.labelId} ${context.unitLabelId}`}\n              data-slot=\"quantity-field-unit\"\n              className={cn(\n                unitSegmentClassName,\n                \"h-full min-h-0 min-w-16 rounded-none py-0 ps-[var(--hui-space-4)] pe-[var(--hui-space-3)] focus-visible:border-s-[var(--hui-color-border-base-primary)] focus-visible:shadow-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[-2px] data-popup-open:bg-[var(--hui-color-background-base-primary-hover)] [&_[data-slot=select-trigger-content]]:[font-weight:var(--hui-font-weight-medium)]\"\n              )}\n            >\n              <SelectValue>{unit.symbol}</SelectValue>\n            </SelectTrigger>\n            <SelectPopup>\n              {units.map((option) => (\n                <SelectItem key={option.key} value={option.key}>\n                  {`${option.name} (${option.symbol})`}\n                </SelectItem>\n              ))}\n            </SelectPopup>\n          </Select>\n        </>\n      )}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Status                                                                     */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Says what the field cannot show in the number alone: a limit that was\n * passed, a paste that was refused or converted, and that the number is a\n * rounded view. Keep it mounted; it is an empty live region until there is\n * something to say, which is what lets screen readers announce the first message.\n */\nfunction QuantityFieldStatus({\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"p\">, \"children\">) {\n  const { statusId, error, notes } = useQuantityFieldContext(\"QuantityFieldStatus\")\n\n  let state = \"empty\"\n  if (notes.length > 0) state = \"note\"\n  if (error !== null) state = \"error\"\n\n  return (\n    <p\n      id={statusId}\n      role=\"status\"\n      data-slot=\"quantity-field-status\"\n      data-state={state}\n      className={cn(\n        \"m-0 flex min-w-0 items-start gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] not-empty:mt-[var(--hui-space-2)] data-[state=error]:text-[var(--hui-color-foreground-base-primary)]\",\n        smallText,\n        className\n      )}\n      {...props}\n    >\n      {/* The danger color passes contrast for an icon but not for small text, so the sentence stays in the text color. */}\n      {error !== null && (\n        <CircleAlertIcon\n          aria-hidden=\"true\"\n          className=\"mt-0.5 size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]\"\n        />\n      )}\n      {state !== \"empty\" && (\n        // Numbers are written for the viewer's locale, which the server may not share.\n        <span suppressHydrationWarning className=\"min-w-0 break-words\">\n          {error ?? notes.join(\" \")}\n        </span>\n      )}\n    </p>\n  )\n}\n\nexport { QuantityField, QuantityFieldControl, QuantityFieldLabel, QuantityFieldStatus }\n"},{"path":"quantity-field-types.ts","type":"registry:component","target":"components/ui/quantity-field/quantity-field-types.ts","content":"export type QuantityUnit<TKey extends string = string> = {\n  /** The stable id used by `unit`, `valueUnit`, and `units`. */\n  key: TKey\n  /** Shown beside the number, such as `min` or `GiB`. */\n  symbol: string\n  /** The plural name read by screen readers and listed in the unit menu, such as `Minutes`. */\n  name: string\n  /** How many of the group's base unit make one of this unit. */\n  factor: number\n  /** Other spellings accepted when a value is pasted. */\n  aliases?: readonly string[] | undefined\n}\n\n/** Units that measure the same thing, so a value in one converts to any other. */\nexport type QuantityUnitGroup<TKey extends string = string> = {\n  /** The unit every `factor` is counted in. `value` uses it unless `valueUnit` says otherwise. */\n  base: TKey\n  units: readonly QuantityUnit<TKey>[]\n  /**\n   * Set when two spellings that differ only by case mean different things,\n   * as `MB` (megabytes) and `Mb` (megabits) do. Pasted units must then match exactly.\n   */\n  caseSensitive?: boolean | undefined\n}\n\nexport type QuantityRounding = {\n  /** The most decimal places shown and accepted in any unit. Defaults to 2. */\n  maximumFractionDigits?: number | undefined\n  /**\n   * The smallest step the stored value can take, counted in `valueUnit`.\n   * Defaults to 1, so a value stored in seconds or bytes is a whole number.\n   */\n  increment?: number | undefined\n}\n\nexport type QuantityRangeError = \"belowMin\" | \"aboveMax\"\n\nexport type QuantityFieldChangeDetails<TKey extends string = string> = {\n  /** The unit shown beside the number when the value changed. */\n  unit: TKey\n  /** The unit the value is counted in. */\n  valueUnit: TKey\n  /** Set when the value is outside `min` and `max`. Typed and pasted values are reported, not clamped. */\n  rangeError: QuantityRangeError | null\n}\n\nexport type NumberSeparators = {\n  decimal: string\n  group: string\n}\n\n/** What a pasted string says, before it is fitted to a field. */\nexport type ParsedQuantity<TKey extends string = string> =\n  /** A number with no unit, to be read in the field's current unit. */\n  | { kind: \"number\"; number: number }\n  | {\n      kind: \"quantity\"\n      /** The total, counted in the group's base unit. */\n      baseValue: number\n      /** The smallest unit named, which shows the total without a remainder. */\n      unit: TKey\n      /** More than one part was given, as in `1h 30m`. */\n      isCompound: boolean\n    }\n\nexport type QuantityFieldLabels = {\n  /** Completes the unit menu's name after the field's label, as in \"Session timeout unit\". */\n  unit: string\n  decrease: string\n  increase: string\n  belowMin: (limit: string) => string\n  aboveMax: (limit: string) => string\n  /** `pasted` is what was on the clipboard. `units` are the symbols this field offers. */\n  unreadablePaste: (pasted: string, units: readonly string[]) => string\n  /** Shown when a pasted value was converted or combined, so the field no longer shows the pasted text. */\n  pasteConverted: (pasted: string, shown: string) => string\n  unitChanged: (from: string, to: string) => string\n  /** Shown while the number in the field is a rounded view of the stored value. */\n  rounded: (exact: string) => string\n}\n"},{"path":"quantity-field-utils.ts","type":"registry:component","target":"components/ui/quantity-field/quantity-field-utils.ts","content":"import type {\n  NumberSeparators,\n  ParsedQuantity,\n  QuantityFieldLabels,\n  QuantityRangeError,\n  QuantityRounding,\n  QuantityUnit,\n  QuantityUnitGroup,\n} from \"./quantity-field-types\"\n\ntype DurationUnitKey = \"ms\" | \"s\" | \"min\" | \"h\" | \"d\" | \"wk\"\n\n/** Elapsed time. A day is exactly 24 hours and a week is 7 of them; calendar months and years are left out because their length varies. */\nexport const durationUnits: QuantityUnitGroup<DurationUnitKey> = {\n  base: \"s\",\n  units: [\n    { key: \"ms\", symbol: \"ms\", name: \"Milliseconds\", factor: 0.001, aliases: [\"msec\", \"msecs\", \"millisecond\", \"milliseconds\"] },\n    { key: \"s\", symbol: \"s\", name: \"Seconds\", factor: 1, aliases: [\"sec\", \"secs\", \"second\", \"seconds\"] },\n    { key: \"min\", symbol: \"min\", name: \"Minutes\", factor: 60, aliases: [\"m\", \"mins\", \"minute\", \"minutes\"] },\n    { key: \"h\", symbol: \"h\", name: \"Hours\", factor: 3600, aliases: [\"hr\", \"hrs\", \"hour\", \"hours\"] },\n    { key: \"d\", symbol: \"d\", name: \"Days\", factor: 86_400, aliases: [\"day\", \"days\"] },\n    { key: \"wk\", symbol: \"wk\", name: \"Weeks\", factor: 604_800, aliases: [\"w\", \"wks\", \"week\", \"weeks\"] },\n  ],\n}\n\ntype StorageUnitKey = \"B\" | \"kB\" | \"MB\" | \"GB\" | \"TB\" | \"KiB\" | \"MiB\" | \"GiB\" | \"TiB\"\n\n/**\n * Data size. `kB`, `MB`, `GB`, and `TB` are powers of 1000; `KiB`, `MiB`,\n * `GiB`, and `TiB` are powers of 1024. Symbols are matched exactly, so `Mb`\n * (megabits) is never read as megabytes.\n */\nexport const storageUnits: QuantityUnitGroup<StorageUnitKey> = {\n  base: \"B\",\n  caseSensitive: true,\n  units: [\n    { key: \"B\", symbol: \"B\", name: \"Bytes\", factor: 1, aliases: [\"byte\", \"bytes\"] },\n    { key: \"kB\", symbol: \"kB\", name: \"Kilobytes\", factor: 1000, aliases: [\"KB\", \"kilobyte\", \"kilobytes\"] },\n    { key: \"MB\", symbol: \"MB\", name: \"Megabytes\", factor: 1000 ** 2, aliases: [\"megabyte\", \"megabytes\"] },\n    { key: \"GB\", symbol: \"GB\", name: \"Gigabytes\", factor: 1000 ** 3, aliases: [\"gigabyte\", \"gigabytes\"] },\n    { key: \"TB\", symbol: \"TB\", name: \"Terabytes\", factor: 1000 ** 4, aliases: [\"terabyte\", \"terabytes\"] },\n    { key: \"KiB\", symbol: \"KiB\", name: \"Kibibytes\", factor: 1024, aliases: [\"kibibyte\", \"kibibytes\"] },\n    { key: \"MiB\", symbol: \"MiB\", name: \"Mebibytes\", factor: 1024 ** 2, aliases: [\"mebibyte\", \"mebibytes\"] },\n    { key: \"GiB\", symbol: \"GiB\", name: \"Gibibytes\", factor: 1024 ** 3, aliases: [\"gibibyte\", \"gibibytes\"] },\n    { key: \"TiB\", symbol: \"TiB\", name: \"Tebibytes\", factor: 1024 ** 4, aliases: [\"tebibyte\", \"tebibytes\"] },\n  ],\n}\n\nconst DEFAULT_MAXIMUM_FRACTION_DIGITS = 2\nconst DEFAULT_INCREMENT = 1\n\n/**\n * Removes the binary noise that multiplying and dividing factors leaves\n * behind, such as 6.000000000000001. Whole numbers pass through untouched, so\n * large byte counts keep every digit.\n */\nfunction trimFloat(value: number) {\n  return Number.isInteger(value) ? value : Number(value.toPrecision(15))\n}\n\nfunction roundHalfAwayFromZero(value: number) {\n  return Math.sign(value) * Math.round(Math.abs(value))\n}\n\nexport function getUnit<TKey extends string>(\n  group: QuantityUnitGroup<TKey>,\n  key: TKey\n): QuantityUnit<TKey> {\n  const unit = group.units.find((candidate) => candidate.key === key)\n  if (!unit) {\n    throw new Error(`QuantityField: \"${key}\" is not a unit in this unit group.`)\n  }\n  return unit\n}\n\n/** Converts without rounding. */\nexport function convertQuantity(value: number, from: QuantityUnit, to: QuantityUnit) {\n  return trimFloat((value * from.factor) / to.factor)\n}\n\nexport function roundToIncrement(value: number, increment = DEFAULT_INCREMENT) {\n  return trimFloat(roundHalfAwayFromZero(trimFloat(value / increment)) * increment)\n}\n\n/**\n * How many decimal places `unit` gets. A unit never gets more places than the\n * stored increment can tell apart: with whole seconds stored, seconds show no\n * decimals and minutes show two.\n */\nexport function getFractionDigits(\n  unit: QuantityUnit,\n  valueUnit: QuantityUnit,\n  rounding: QuantityRounding = {}\n) {\n  const maximum = rounding.maximumFractionDigits ?? DEFAULT_MAXIMUM_FRACTION_DIGITS\n  const increment = rounding.increment ?? DEFAULT_INCREMENT\n  const incrementsPerUnit = unit.factor / (valueUnit.factor * increment)\n  const useful = Math.ceil(Math.log10(incrementsPerUnit) - 1e-9)\n  return Math.min(Math.max(useful, 0), maximum)\n}\n\n/** The number the field shows for a stored value: converted to `unit`, then rounded half away from zero. */\nexport function toDisplayNumber(\n  value: number,\n  unit: QuantityUnit,\n  valueUnit: QuantityUnit,\n  rounding?: QuantityRounding\n) {\n  const digits = getFractionDigits(unit, valueUnit, rounding)\n  const scale = 10 ** digits\n  return roundHalfAwayFromZero(trimFloat(convertQuantity(value, valueUnit, unit) * scale)) / scale\n}\n\n/** Whether the shown number, read back in `unit`, is the stored value and not a rounded view of it. */\nexport function isDisplayExact(\n  value: number,\n  unit: QuantityUnit,\n  valueUnit: QuantityUnit,\n  rounding?: QuantityRounding\n) {\n  const shown = toDisplayNumber(value, unit, valueUnit, rounding)\n  const readBack = convertQuantity(shown, unit, valueUnit)\n  return Math.abs(readBack - value) <= Math.max(1, Math.abs(value)) * 1e-9\n}\n\n/** The stored value for a number entered in `unit`, rounded to the stored increment. */\nexport function fromDisplayNumber(\n  shown: number,\n  unit: QuantityUnit,\n  valueUnit: QuantityUnit,\n  rounding: QuantityRounding = {}\n) {\n  return roundToIncrement(convertQuantity(shown, unit, valueUnit), rounding.increment)\n}\n\nfunction clamp(value: number, min: number | undefined, max: number | undefined) {\n  if (min !== undefined && value < min) return min\n  if (max !== undefined && value > max) return max\n  return value\n}\n\nexport function getRangeError(\n  value: number | null,\n  min: number | undefined,\n  max: number | undefined\n): QuantityRangeError | null {\n  if (value === null) return null\n  if (min !== undefined && value < min) return \"belowMin\"\n  if (max !== undefined && value > max) return \"aboveMax\"\n  return null\n}\n\ntype DisplayChange = {\n  /** The stored value before the change. */\n  value: number | null\n  /** The number the number field reports. */\n  next: number | null\n  /**\n   * The change came from a stepper, an arrow key, or the wheel. It is applied\n   * as a difference to the exact stored value and kept inside the limits.\n   */\n  isStep: boolean\n  unit: QuantityUnit\n  valueUnit: QuantityUnit\n  rounding?: QuantityRounding | undefined\n  min?: number | undefined\n  max?: number | undefined\n}\n\n/**\n * Turns a change to the shown number into the next stored value.\n *\n * The shown number can be a rounded view (100 min shows as 1.67 h), so two\n * cases must not be read literally. The number field reporting the number it\n * already shows, as it does on blur, leaves the stored value alone. A step\n * adds its size to the stored value, so 1.67 h plus one hour is 160 minutes\n * and not 2.67 hours.\n */\nexport function applyDisplayChange({\n  value,\n  next,\n  isStep,\n  unit,\n  valueUnit,\n  rounding,\n  min,\n  max,\n}: DisplayChange): number | null {\n  if (next === null) return null\n\n  const shown = value === null ? null : toDisplayNumber(value, unit, valueUnit, rounding)\n  if (shown !== null && next === shown) return isStep ? clamp(value as number, min, max) : value\n\n  if (!isStep) return fromDisplayNumber(next, unit, valueUnit, rounding)\n\n  if (value === null || shown === null) {\n    return clamp(fromDisplayNumber(next, unit, valueUnit, rounding), min, max)\n  }\n\n  const difference = convertQuantity(next - shown, unit, valueUnit)\n  return clamp(roundToIncrement(value + difference, rounding?.increment), min, max)\n}\n\n/** The decimal and grouping characters `locale` writes numbers with. */\nexport function getNumberSeparators(locale?: Intl.LocalesArgument): NumberSeparators {\n  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6)\n  return {\n    decimal: parts.find((part) => part.type === \"decimal\")?.value ?? \".\",\n    group: parts.find((part) => part.type === \"group\")?.value ?? \",\",\n  }\n}\n\nconst SPACE_LIKE = /[   ]/g\nconst APOSTROPHE_LIKE = /[’']/g\n\nfunction escapeForRegExp(text: string) {\n  return text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n}\n\n/**\n * Reads an unsigned number written with the locale's separators. Grouping is\n * accepted only in threes, so `1,5` in an English locale is refused instead of\n * being read as 15. A plain space is accepted as a grouping character in every locale.\n */\nfunction parseNumberToken(token: string, separators: NumberSeparators): number | null {\n  const decimal = separators.decimal\n  const localeGroup = separators.group.replace(SPACE_LIKE, \" \").replace(APOSTROPHE_LIKE, \"'\")\n  const groups = [...new Set([localeGroup, \" \"])].filter((group) => group !== decimal)\n  const groupPattern = `[${groups.map(escapeForRegExp).join(\"\")}]`\n\n  const match = new RegExp(\n    `^(\\\\d{1,3}(?:${groupPattern}\\\\d{3})+|\\\\d+)?(?:${escapeForRegExp(decimal)}(\\\\d+))?$`\n  ).exec(token.replace(APOSTROPHE_LIKE, \"'\"))\n  if (!match) return null\n\n  const [, whole = \"\", fraction = \"\"] = match\n  if (whole === \"\" && fraction === \"\") return null\n\n  const value = Number(`${whole.replace(/\\D/g, \"\") || \"0\"}.${fraction || \"0\"}`)\n  return Number.isFinite(value) ? value : null\n}\n\nfunction findUnitByText<TKey extends string>(\n  group: QuantityUnitGroup<TKey>,\n  text: string\n): QuantityUnit<TKey> | null {\n  const spellings = (unit: QuantityUnit<TKey>) => [unit.symbol, unit.key, ...(unit.aliases ?? [])]\n\n  const exact = group.units.find((unit) => spellings(unit).includes(text))\n  if (exact || group.caseSensitive) return exact ?? null\n\n  const lowered = text.toLowerCase()\n  const loose = group.units.filter((unit) =>\n    spellings(unit).some((spelling) => spelling.toLowerCase() === lowered)\n  )\n  return loose.length === 1 ? loose[0] : null\n}\n\n/**\n * Reads pasted text such as `90 min`, `1.5h`, `1h 30m`, `1,024 MiB`, or `90`.\n * Returns `null` when the text is not a number, names a unit outside the\n * group, mixes parts with and without units, or lists parts out of order.\n */\nexport function parseQuantityText<TKey extends string>(\n  text: string,\n  group: QuantityUnitGroup<TKey>,\n  separators: NumberSeparators\n): ParsedQuantity<TKey> | null {\n  let rest = text.replace(SPACE_LIKE, \" \").trim()\n  // A value copied out of a sentence often brings its closing punctuation along.\n  rest = rest.replace(/(?<=\\p{L})[.,;]$/u, \"\")\n  if (rest === \"\") return null\n\n  const sign = /^[+\\-−]/.exec(rest)\n  const isNegative = sign !== null && sign[0] !== \"+\"\n  if (sign) rest = rest.slice(1).trimStart()\n\n  const partPattern = /^([\\d.,'’ ]*\\d)\\s*([\\p{L}µ]*)\\s*/u\n  const parts: { number: number; unit: QuantityUnit<TKey> | null }[] = []\n\n  while (rest !== \"\") {\n    const match = partPattern.exec(rest)\n    if (!match) return null\n\n    const number = parseNumberToken(match[1].trim(), separators)\n    if (number === null) return null\n\n    const unit = match[2] === \"\" ? null : findUnitByText(group, match[2])\n    if (match[2] !== \"\" && unit === null) return null\n\n    parts.push({ number, unit })\n    rest = rest.slice(match[0].length)\n  }\n\n  if (parts.length === 1 && parts[0].unit === null) {\n    return { kind: \"number\", number: isNegative ? -parts[0].number : parts[0].number }\n  }\n\n  let baseValue = 0\n  let previousFactor = Infinity\n  let smallest: QuantityUnit<TKey> | null = null\n\n  for (const part of parts) {\n    // `1h 30` and `30m 1h` are more likely mistakes than quantities.\n    if (part.unit === null || part.unit.factor >= previousFactor) return null\n    baseValue += part.number * part.unit.factor\n    previousFactor = part.unit.factor\n    smallest = part.unit\n  }\n\n  if (smallest === null) return null\n\n  return {\n    kind: \"quantity\",\n    baseValue: trimFloat(isNegative ? -baseValue : baseValue),\n    unit: smallest.key,\n    isCompound: parts.length > 1,\n  }\n}\n\ntype PasteContext<TKey extends string> = {\n  group: QuantityUnitGroup<TKey>\n  /** The units the field offers. */\n  units: readonly QuantityUnit<TKey>[]\n  /** The unit the field shows now. */\n  unit: QuantityUnit<TKey>\n  valueUnit: QuantityUnit<TKey>\n  rounding?: QuantityRounding | undefined\n}\n\n/**\n * Fits a pasted quantity to a field. The field switches to the pasted unit\n * when it offers that unit, so `90 min` stays `90 min`. Otherwise the value is\n * converted into the unit already shown and `wasConverted` is set.\n */\nexport function resolvePaste<TKey extends string>(\n  parsed: ParsedQuantity<TKey>,\n  { group, units, unit, valueUnit, rounding }: PasteContext<TKey>\n): { value: number; unit: QuantityUnit<TKey>; wasConverted: boolean } {\n  if (parsed.kind === \"number\") {\n    return {\n      value: fromDisplayNumber(parsed.number, unit, valueUnit, rounding),\n      unit,\n      wasConverted: false,\n    }\n  }\n\n  const pastedUnit = units.find((candidate) => candidate.key === parsed.unit)\n  const value = roundToIncrement(\n    convertQuantity(parsed.baseValue, getUnit(group, group.base), valueUnit),\n    rounding?.increment\n  )\n\n  return {\n    value,\n    unit: pastedUnit ?? unit,\n    wasConverted: parsed.isCompound || pastedUnit === undefined,\n  }\n}\n\nfunction formatNumber(value: number, locale: Intl.LocalesArgument | undefined) {\n  return new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }).format(value)\n}\n\n/** The stored value as the field shows it in `unit`, such as `1.5 h`. */\nexport function formatQuantity(\n  value: number,\n  unit: QuantityUnit,\n  valueUnit: QuantityUnit,\n  options: { rounding?: QuantityRounding | undefined; locale?: Intl.LocalesArgument | undefined } = {}\n) {\n  const shown = toDisplayNumber(value, unit, valueUnit, options.rounding)\n  return `${formatNumber(shown, options.locale)} ${unit.symbol}`\n}\n\n/**\n * The stored value written without rounding: in `preferred` when that unit\n * shows it exactly, otherwise in the largest offered unit that does, and\n * failing that in the unit it is stored in.\n */\nexport function formatExactQuantity(\n  value: number,\n  {\n    preferred,\n    units,\n    valueUnit,\n    rounding,\n    locale,\n  }: {\n    preferred: QuantityUnit\n    units: readonly QuantityUnit[]\n    valueUnit: QuantityUnit\n    rounding?: QuantityRounding | undefined\n    locale?: Intl.LocalesArgument | undefined\n  }\n) {\n  const largestFirst = [...units].sort((a, b) => b.factor - a.factor)\n  const exactUnit = [preferred, ...largestFirst].find((unit) =>\n    isDisplayExact(value, unit, valueUnit, rounding)\n  )\n\n  return exactUnit\n    ? formatQuantity(value, exactUnit, valueUnit, { rounding, locale })\n    : `${formatNumber(value, locale)} ${valueUnit.symbol}`\n}\n\nexport const defaultQuantityFieldLabels: QuantityFieldLabels = {\n  unit: \"unit\",\n  decrease: \"Decrease\",\n  increase: \"Increase\",\n  belowMin: (limit) => `Enter ${limit} or more.`,\n  aboveMax: (limit) => `Enter ${limit} or less.`,\n  unreadablePaste: (pasted, units) =>\n    `“${pasted}” was not pasted because it could not be read. Use a number with one of these units: ${units.join(\", \")}.`,\n  pasteConverted: (pasted, shown) => `“${pasted}” was pasted as ${shown}.`,\n  unitChanged: (from, to) => `${from} is ${to}.`,\n  rounded: (exact) => `Shown rounded. The exact value is ${exact}.`,\n}\n"}],"dependencies":["honestui"],"registryDependencies":["https://www.honestui.com/r/label.json","https://www.honestui.com/r/number-field.json","https://www.honestui.com/r/select.json"]}