{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-group",
  "title": "Input Group",
  "description": "Icons, text, keys and buttons set inside an input or textarea's box.",
  "dependencies": [
    "@base-ui/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/button.json",
    "https://constella.aayush.cv/r/textarea.json"
  ],
  "files": [
    {
      "path": "src/components/primitives/input-group.tsx",
      "content": "\"use client\";\n\nimport { Input as InputPrimitive } from \"@base-ui/react/input\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport type { ComponentProps } from \"react\";\n\nimport { Button } from \"@/components/primitives/button\";\nimport type { ButtonProps } from \"@/components/primitives/button\";\nimport { Textarea, textareaStyles } from \"@/components/primitives/textarea\";\nimport type { TextareaProps } from \"@/components/primitives/textarea\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { joinStyles, typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  easings,\n  fontSizes,\n  fontWeights,\n  inputVars,\n  joins,\n  lineHeights,\n  media,\n  opacities,\n  presses,\n  radii,\n  shadows,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\nconst inputGroupAddonAligns = [\n  \"inline-start\",\n  \"inline-end\",\n  \"block-start\",\n  \"block-end\",\n] as const;\nconst inputGroupButtonSizes = [\"xs\", \"sm\", \"icon-xs\", \"icon-sm\"] as const;\nconst inputGroupSizes = [\"sm\", \"default\", \"lg\"] as const;\n\ntype InputGroupAddonAlign = (typeof inputGroupAddonAligns)[number];\ntype InputGroupButtonSize = (typeof inputGroupButtonSizes)[number];\ntype InputGroupSize = (typeof inputGroupSizes)[number];\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\ntype InputGroupProps = Styled<ComponentProps<\"div\">> & {\n  size?: InputGroupSize;\n};\n\ntype InputGroupAddonProps = Styled<ComponentProps<\"div\">> & {\n  align?: InputGroupAddonAlign;\n  /** Draws an edge between a block addon and the control. */\n  separated?: boolean;\n};\n\ntype InputGroupButtonProps = Omit<ButtonProps, \"size\"> & {\n  size?: InputGroupButtonSize;\n};\n\n// Native `size` counts characters; width comes from layout here.\ntype InputGroupInputProps = Omit<\n  InputPrimitive.Props,\n  \"className\" | \"size\" | \"style\"\n> & {\n  sx?: Sx;\n};\n\nconst control = \"[data-slot='input-group-control']\";\nconst invalid = `:has(${control}:is([aria-invalid='true'], [data-invalid]))`;\nconst disabled = `:has(${control}:is(:disabled, [data-disabled]))`;\nconst controlDisabled = \":is(:disabled, [data-disabled])\";\nconst sm = \":is([data-size='sm'])\";\n// Not `:dir(rtl)`: Lightning CSS lowers it to a `:lang()` list that ignores `dir`.\nconst rtl = \":is([dir='rtl'] *)\";\n// The first button in a run, inside a block addon.\nconst leadingBlockButton =\n  \":where([data-slot='input-group-addon'][data-align^='block'] > :not([data-slot='button'] + *))\";\n// A text prefix like `https://` or `$`, or a suffix like `%`, reads as part of the value. `:is`, not `:where`, so it outranks the inline padding.\nconst afterPrefix =\n  \":is([data-slot='input-group']:has(> [data-align='inline-start'] > [data-slot='input-group-text']:only-child) *)\";\nconst beforeSuffix =\n  \":is([data-slot='input-group']:has(> [data-align='inline-end'] > [data-slot='input-group-text']:only-child) *)\";\n// A trailing button sits as far from the side as from the edge it rests on.\nconst trailingButton = \":has(> [data-slot='button']:last-child)\";\nconst stacked = \":has(> [data-align^='block'], > textarea)\";\n\n// Conditions on the control, read from the group around it.\nconst inGroupWith = (align: InputGroupAddonAlign) =>\n  `:where([data-slot='input-group']:has(> [data-align='${align}']) *)`;\nconst inGroupSized = (size: InputGroupSize) =>\n  `:where([data-slot='input-group'][data-size='${size}'] *)`;\n\n// Per group height: the size's value, `default` unless set.\nconst bySize = (values: Record<InputGroupSize, string>) => ({\n  default: values.default,\n  [inGroupSized(\"sm\")]: values.sm,\n  [inGroupSized(\"lg\")]: values.lg,\n});\n\nconst insetMargin = (group: string, item: string) =>\n  `calc((${group} - ${item}) / 2 - ${strokes.border} - ${space.xs})`;\n\nconst stackedInput = `calc(${lineHeights.textSm} + ${space.xs} + ${space.xxs})`;\nconst chipInset = `calc((${sizes.controlMd} - ${sizes.controlXs}) / 2 - ${strokes.border})`;\nconst unscaled = (length: string) => `calc(${length} / ${inputVars.scale})`;\n// An xs button or a keycap sits in the box with an even inset on three sides.\nconst insetEdge = {\n  default: null,\n  \":has(> [data-slot='button'])\": bySize({\n    lg: insetMargin(sizes.controlLg, sizes.controlXs),\n    default: insetMargin(sizes.controlMd, sizes.controlXs),\n    sm: insetMargin(sizes.controlSm, sizes.controlXs),\n  }),\n  \":has(> [data-slot='kbd'])\": bySize({\n    lg: insetMargin(sizes.controlLg, sizes.kbd),\n    default: insetMargin(sizes.controlMd, sizes.kbd),\n    sm: insetMargin(sizes.controlSm, sizes.kbd),\n  }),\n};\n\nconst styles = stylex.create({\n  group: {\n    alignItems: { default: \"center\", [stacked]: \"stretch\" },\n    backgroundClip: \"padding-box\",\n    backgroundColor: colors.background,\n    borderBlockEndColor: {\n      default: colors.border,\n      [invalid]: colors.dangerBorder,\n    },\n    borderBlockEndStyle: \"solid\",\n    borderBlockEndWidth: strokes.border,\n    borderBlockStartColor: {\n      default: colors.border,\n      [invalid]: colors.dangerBorder,\n    },\n    borderBlockStartStyle: \"solid\",\n    borderBlockStartWidth: strokes.border,\n    borderInlineEndColor: {\n      default: colors.border,\n      [invalid]: colors.dangerBorder,\n    },\n    borderInlineEndStyle: \"solid\",\n    borderInlineEndWidth: strokes.border,\n    borderInlineStartColor: {\n      default: colors.border,\n      [invalid]: colors.dangerBorder,\n    },\n    borderInlineStartStyle: \"solid\",\n    borderInlineStartWidth: strokes.border,\n    boxShadow: { default: shadows.default, [invalid]: shadows.defaultInvalid },\n    cursor: { default: null, [disabled]: \"not-allowed\" },\n    display: \"flex\",\n    flexDirection: { default: \"row\", [stacked]: \"column\" },\n    fontSize: { default: fontSizes.textSm, [sm]: fontSizes.captionLg },\n    height: {\n      default: sizes.controlMd,\n      [sm]: sizes.controlSm,\n      \":is([data-size='lg'])\": sizes.controlLg,\n      [stacked]: \"auto\",\n    },\n    [inputVars.scale]: {\n      default: null,\n      [media.coarse]: { default: \"calc(13 / 16)\", [sm]: \"calc(12 / 16)\" },\n    },\n    lineHeight: { default: lineHeights.textSm, [sm]: lineHeights.captionLg },\n    minWidth: 0,\n    opacity: { default: 1, [disabled]: opacities.disabled },\n    position: \"relative\",\n    transitionDuration: durations.hover,\n    transitionProperty: \"border-color, box-shadow\",\n    transitionTimingFunction: \"ease\",\n    width: \"100%\",\n    // In a ButtonGroup, the focused group's ring stays above its neighbors.\n    zIndex: { default: null, \":focus-within\": 1 },\n  },\n  addon: {\n    // A ButtonGroup's joins stop at the group, so the buttons inside stay whole.\n    [joins.block]: \"1\",\n    [joins.either]: \"1\",\n    [joins.inline]: \"1\",\n    alignItems: \"center\",\n    color: colors.textMuted,\n    cursor: \"text\",\n    display: \"flex\",\n    flexShrink: 0,\n    fontWeight: fontWeights.medium,\n    gap: space.xs,\n    justifyContent: \"center\",\n    // The control's line, so text above or below it sits the same distance from the edge.\n    paddingBlockEnd: space.row,\n    paddingBlockStart: space.row,\n    userSelect: \"none\",\n  },\n  inlineStart: {\n    marginInlineStart: insetEdge,\n    order: -1,\n    paddingInlineStart: space.xs,\n  },\n  inlineEnd: {\n    marginInlineEnd: insetEdge,\n    order: 1,\n    paddingInlineEnd: space.xs,\n  },\n  blockStart: {\n    justifyContent: \"flex-start\",\n    order: -1,\n    paddingBlockEnd: 0,\n    paddingBlockStart: space.xs,\n    paddingInlineEnd: { default: space.popup, [trailingButton]: space.xs },\n    paddingInlineStart: space.popup,\n  },\n  blockEnd: {\n    justifyContent: \"flex-start\",\n    order: 1,\n    paddingBlockEnd: space.xs,\n    paddingBlockStart: 0,\n    paddingInlineEnd: { default: space.popup, [trailingButton]: space.xs },\n    paddingInlineStart: space.popup,\n  },\n  separatedStart: {\n    borderBlockEndColor: colors.borderSubtle,\n    borderBlockEndStyle: \"solid\",\n    borderBlockEndWidth: strokes.border,\n    paddingBlockEnd: space.xs,\n  },\n  separatedEnd: {\n    borderBlockStartColor: colors.borderSubtle,\n    borderBlockStartStyle: \"solid\",\n    borderBlockStartWidth: strokes.border,\n    paddingBlockStart: space.xs,\n  },\n  text: {\n    alignItems: \"center\",\n    color: colors.textMuted,\n    display: \"flex\",\n    gap: space.xs,\n  },\n  // The group draws the surface, edge, ring and fade, so the control drops its own.\n  control: {\n    backgroundColor: \"transparent\",\n    borderEndEndRadius: 0,\n    borderEndStartRadius: 0,\n    borderStartEndRadius: 0,\n    borderStartStartRadius: 0,\n    borderBlockEndWidth: 0,\n    borderBlockStartWidth: 0,\n    borderInlineEndWidth: 0,\n    borderInlineStartWidth: 0,\n    boxShadow: \"none\",\n    flexGrow: 1,\n    opacity: 1,\n  },\n  // The input can outgrow this slot at 16px, then scale back to fit.\n  inputSlot: {\n    alignSelf: \"stretch\",\n    display: \"flex\",\n    flexGrow: 1,\n    height: {\n      default: null,\n      // One line, the outer edge's gap, and a step to the addon so the two lines space evenly.\n      [inGroupWith(\"block-start\")]: stackedInput,\n      [inGroupWith(\"block-end\")]: stackedInput,\n    },\n    minWidth: 0,\n  },\n  input: {\n    backgroundColor: \"transparent\",\n    borderBlockEndWidth: 0,\n    borderBlockStartWidth: 0,\n    borderInlineEndWidth: 0,\n    borderInlineStartWidth: 0,\n    caretColor: colors.accentSolid,\n    color: colors.textPrimary,\n    cursor: { default: null, [controlDisabled]: \"not-allowed\" },\n    flexShrink: 0,\n    // Under 16px, iOS Safari zooms the page on focus.\n    fontSize: {\n      default: unscaled(\"1em\"),\n      // A smaller root or text step than the scale assumes would drop it under again.\n      [media.coarse]: `max(16px, ${unscaled(\"1em\")})`,\n    },\n    height: unscaled(\"100%\"),\n    marginBlockEnd: 0,\n    marginBlockStart: 0,\n    marginInlineEnd: 0,\n    marginInlineStart: 0,\n    minWidth: 0,\n    paddingBlockEnd: {\n      default: 0,\n      [inGroupWith(\"block-start\")]: unscaled(space.xs),\n      [inGroupWith(\"block-end\")]: unscaled(space.xxs),\n    },\n    paddingBlockStart: {\n      default: 0,\n      [inGroupWith(\"block-start\")]: unscaled(space.xxs),\n      [inGroupWith(\"block-end\")]: unscaled(space.xs),\n    },\n    paddingInlineEnd: {\n      default: unscaled(space.popup),\n      [inGroupWith(\"inline-end\")]: unscaled(space.row),\n      [beforeSuffix]: unscaled(space.xxxs),\n    },\n    paddingInlineStart: {\n      default: unscaled(space.popup),\n      [inGroupWith(\"inline-start\")]: unscaled(space.row),\n      [afterPrefix]: unscaled(space.xxxs),\n    },\n    scale: { default: null, [media.coarse]: inputVars.scale },\n    transformOrigin: {\n      default: null,\n      [media.coarse]: { default: \"left top\", [rtl]: \"right top\" },\n    },\n    width: unscaled(\"100%\"),\n    \"::placeholder\": { color: colors.textMuted },\n    \"::file-selector-button\": {\n      backgroundColor: {\n        default: colors.fill,\n        [media.hover]: {\n          default: colors.fill,\n          \":hover:not(:disabled)\": colors.fillStrong,\n        },\n        \":active:not(:disabled)\": colors.fillStrong,\n      },\n      borderBlockEndWidth: 0,\n      borderBlockStartWidth: 0,\n      borderInlineEndWidth: 0,\n      borderInlineStartWidth: 0,\n      borderStartStartRadius: radii.xs,\n      borderStartEndRadius: radii.xs,\n      borderEndStartRadius: radii.xs,\n      borderEndEndRadius: radii.xs,\n      color: colors.textPrimary,\n      cursor: \"inherit\",\n      fontSize: fontSizes.textSm,\n      lineHeight: lineHeights.textSm,\n      fontWeight: fontWeights.medium,\n      height: \"100%\",\n      marginInlineEnd: space.xs,\n      paddingBlockEnd: 0,\n      paddingBlockStart: 0,\n      paddingInlineEnd: space.xs,\n      paddingInlineStart: space.xs,\n      transform: {\n        default: null,\n        \":active\": { default: presses.link, [media.reducedMotion]: null },\n      },\n      transitionDuration: `${durations.hover}, ${durations.press}`,\n      transitionProperty: \"background-color, transform\",\n      transitionTimingFunction: `ease, ${easings.out}`,\n    },\n  },\n  // The chip sits in the box with an even inset on three sides, its corner concentric with the box's.\n  file: {\n    // A file input opens a picker, so iOS never zooms it.\n    [inputVars.scale]: \"1\",\n    color: colors.textSecondary,\n    fontSize: \"inherit\",\n    cursor: { default: \"pointer\", [controlDisabled]: \"not-allowed\" },\n    paddingBlockEnd: chipInset,\n    paddingBlockStart: chipInset,\n    paddingInlineStart: chipInset,\n  },\n  textarea: {\n    resize: \"none\",\n  },\n  button: {\n    boxShadow: \"none\",\n    // In a header or footer the actions gather at the end, after any text.\n    marginInlineStart: { default: null, [leadingBlockButton]: \"auto\" },\n  },\n  // Concentric with the group's corner at the even inset.\n  buttonXs: {\n    gap: space.xxs,\n    paddingInlineEnd: space.row,\n    paddingInlineStart: space.row,\n  },\n});\n\nconst alignStyles = {\n  \"block-end\": styles.blockEnd,\n  \"block-start\": styles.blockStart,\n  \"inline-end\": styles.inlineEnd,\n  \"inline-start\": styles.inlineStart,\n} satisfies Record<InputGroupAddonAlign, stylex.StyleXStyles>;\n\n// Only a header or footer has an edge to draw.\nconst separatedStyles = {\n  \"block-end\": styles.separatedEnd,\n  \"block-start\": styles.separatedStart,\n  \"inline-end\": null,\n  \"inline-start\": null,\n} satisfies Record<InputGroupAddonAlign, stylex.StyleXStyles | null>;\n\nconst buttonSizeStyles = {\n  \"icon-sm\": null,\n  \"icon-xs\": joinStyles.xs,\n  sm: null,\n  xs: [[typeStyles.textSm, styles.buttonXs], joinStyles.xs],\n} satisfies Record<InputGroupButtonSize, stylex.StyleXStyles | null>;\n\nfunction InputGroup({ size = \"default\", sx, ...props }: InputGroupProps) {\n  return (\n    <div\n      data-size={size}\n      data-slot=\"input-group\"\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a fieldset brings its own border and padding into a flex row\n      role=\"group\"\n      {...props}\n      {...stylex.props(styles.group, joinStyles.md, joinStyles.edges, sx)}\n    />\n  );\n}\n\nfunction InputGroupAddon({\n  align = \"inline-start\",\n  onClick,\n  separated = false,\n  sx,\n  ...props\n}: InputGroupAddonProps) {\n  return (\n    // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- A pointer shortcut to the control; keyboard users tab straight to it.\n    <div\n      data-align={align}\n      data-separated={separated || undefined}\n      data-slot=\"input-group-addon\"\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.target instanceof Element && event.target.closest(\"button\")) {\n          return;\n        }\n        event.currentTarget.parentElement\n          ?.querySelector<HTMLElement>(control)\n          ?.focus();\n      }}\n      {...props}\n      {...stylex.props(\n        [typeStyles.textSm, styles.addon],\n        alignStyles[align],\n        separated && separatedStyles[align],\n        sx\n      )}\n    />\n  );\n}\n\nfunction InputGroupButton({\n  size = \"xs\",\n  sx,\n  type = \"button\",\n  variant = \"ghost\",\n  ...props\n}: InputGroupButtonProps) {\n  return (\n    <Button\n      size={size}\n      type={type}\n      variant={variant}\n      {...props}\n      sx={[styles.button, buttonSizeStyles[size], sx]}\n    />\n  );\n}\n\nfunction InputGroupText({\n  render,\n  sx,\n  ...props\n}: Styled<useRender.ComponentProps<\"span\">>) {\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(\n      props,\n      stylex.props([typeStyles.textSm, styles.text], sx)\n    ),\n    render,\n    state: { slot: \"input-group-text\" },\n  });\n}\n\n/** The control's styles for your own textarea; pair them with `data-slot=\"input-group-control\"`. */\nfunction inputGroupTextareaStyles() {\n  return [...textareaStyles(), styles.control, styles.textarea];\n}\n\nfunction InputGroupInput({ sx, type, ...props }: InputGroupInputProps) {\n  return (\n    <span data-slot=\"input-group-input\" {...stylex.props(styles.inputSlot)}>\n      <InputPrimitive\n        type={type}\n        {...props}\n        data-slot=\"input-group-control\"\n        {...stylex.props(styles.input, type === \"file\" && styles.file, sx)}\n      />\n    </span>\n  );\n}\n\nfunction InputGroupTextarea({ sx, ...props }: TextareaProps) {\n  return (\n    <Textarea\n      data-slot=\"input-group-control\"\n      {...props}\n      sx={[styles.control, styles.textarea, sx]}\n    />\n  );\n}\n\nexport {\n  InputGroup,\n  InputGroupAddon,\n  inputGroupAddonAligns,\n  InputGroupButton,\n  inputGroupButtonSizes,\n  InputGroupInput,\n  inputGroupSizes,\n  InputGroupText,\n  InputGroupTextarea,\n  inputGroupTextareaStyles,\n};\nexport type {\n  InputGroupAddonAlign,\n  InputGroupAddonProps,\n  InputGroupButtonProps,\n  InputGroupButtonSize,\n  InputGroupInputProps,\n  InputGroupProps,\n  InputGroupSize,\n};\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/input-group/index.md",
  "type": "registry:ui"
}