Aller au contenu

Groupe de champs

Ajoutez des icônes, des boutons et des infobulles aux champs de saisie des barres de recherche, des champs d'URL et des fenêtres de discussion, avec des bordures et des ombres de style néobrutaliste.

import { Search } from "lucide-react"

import {

Le groupe de saisie enveloppe un champ ou une zone de texte avec ses compléments — icônes, texte en ligne, boutons, rappels de raccourcis — dans un seul contrôle bordé. C’est une composition maison des composants Input, Textarea et Button de cette bibliothèque, habillée selon la recette néobrutaliste : bordures épaisses, ombres franches et typographie affirmée.

À privilégier pour :

  • Champs de recherche — une icône de recherche en tête, plus un rappel ⌘K ou un bouton d’effacement en queue.
  • Saisies préfixées et suffixées — un texte fixe https://, @ ou .com pour que l’utilisateur ne tape que la partie variable.
  • Compositeurs de chat et de prompt — une zone de texte avec une barre d’outils block-end de boutons et une action d’envoi.

Installation

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
npx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/input-group.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/input-group.json

Copiez-collez le code suivant dans votre projet.

components/ui/input-group.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-group"
      role="group"
      className={cn(
        "group/input-group relative flex h-8 w-full min-w-0 items-center rounded border-2 bg-input shadow-sm transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-offset-2 has-[[data-slot=input-group-control]:focus-visible]:outline-primary has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
        className
      )}
      {...props}
    />
  )
}

const inputGroupAddonVariants = cva(
  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        "inline-start":
          "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
        "inline-end":
          "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
        "block-start":
          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
        "block-end":
          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
    },
    defaultVariants: {
      align: "inline-start",
    },
  }
)

function InputGroupAddon({
  className,
  align = "inline-start",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
  return (
    <div
      role="group"
      data-slot="input-group-addon"
      data-align={align}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest("button")) {
          return
        }
        e.currentTarget.parentElement?.querySelector("input")?.focus()
      }}
      {...props}
    />
  )
}

const inputGroupButtonVariants = cva(
  "flex items-center gap-2 text-sm shadow-none",
  {
    variants: {
      size: {
        xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
        sm: "",
        "icon-xs":
          "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
        "icon-sm": "size-8 p-0 has-[>svg]:p-0",
      },
    },
    defaultVariants: {
      size: "xs",
    },
  }
)

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
  VariantProps<typeof inputGroupButtonVariants>) {
  return (
    <Button
      type={type}
      data-size={size}
      variant={variant}
      className={cn(inputGroupButtonVariants({ size }), className)}
      {...props}
    />
  )
}

function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      className={cn(
        "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function InputGroupInput({
  className,
  ...props
}: React.ComponentProps<"input">) {
  return (
    <Input
      data-slot="input-group-control"
      className={cn(
        "flex-1 rounded-none border-0 bg-transparent shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

function InputGroupTextarea({
  className,
  ...props
}: React.ComponentProps<"textarea">) {
  return (
    <Textarea
      data-slot="input-group-control"
      className={cn(
        "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

export {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupInput,
  InputGroupTextarea,
}

Adaptez les chemins d’import à la structure de votre projet.

Utilisation

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"
<InputGroup>
  <InputGroupInput placeholder="Search..." />
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
</InputGroup>

Composition

Utilisez la composition suivante pour construire un InputGroup :

InputGroup
├── InputGroupInput or InputGroupTextarea
├── InputGroupAddon
├── InputGroupButton
└── InputGroupText

Alignement

Utilisez la prop align de InputGroupAddon pour positionner le complément par rapport au champ de saisie.

inline-start

Utilisez align="inline-start" pour placer le complément au début du champ de saisie. C’est la valeur par défaut.

import { SearchIcon } from "lucide-react"

import {

inline-end

Utilisez align="inline-end" pour placer le complément à la fin du champ de saisie.

import { EyeOffIcon } from "lucide-react"

import {

block-start

Utilisez align="block-start" pour placer le complément au-dessus du champ de saisie.

import { CopyIcon, FileCodeIcon } from "lucide-react"

import {

block-end

Utilisez align="block-end" pour placer le complément sous le champ de saisie.

import {
  Field,
  FieldDescription,

Exemples

Icône

import {
  CheckIcon,
  CreditCardIcon,

Texte

import {
  InputGroup,
  InputGroupAddon,

Bouton

"use client"

import * as React from "react"

Kbd

import { SearchIcon } from "lucide-react"

import {
"use client"

import { ChevronDownIcon, MoreHorizontal } from "lucide-react"

Spinner

import { LoaderIcon } from "lucide-react"

import {

Textarea

import {
  IconBrandJavascript,
  IconCopy,

Champ personnalisé

Ajoutez l’attribut data-slot="input-group-control" à votre champ personnalisé pour une gestion automatique de l’état de focus.

Voici un exemple de zone de texte personnalisée et redimensionnable provenant d’une bibliothèque tierce.

"use client"

import TextareaAutosize from "react-textarea-autosize"

RTL

Pour activer le RTL dans Neobrutalism, consultez le guide de configuration RTL.

"use client"

import * as React from "react"

Accessibilité

Il n’existe pas de modèle de widget ARIA pour les groupes de saisie — l’enveloppe rend role="group" pour que les technologies d’assistance associent les compléments au contrôle, et l’accès clavier est natif : Tab atteint le champ puis chaque InputGroupButton dans l’ordre du DOM, raison pour laquelle les compléments se placent après le champ (et se positionnent visuellement avec align). Deux points restent à votre charge : un texte de complément comme https:// est visuel, pas une étiquette programmatique, le champ a donc toujours besoin d’un <Label> ou d’un aria-label ; et les boutons à icône seule exigent un aria-label — cliquer sur un complément donne le focus au champ, mais c’est une commodité au pointeur, pas un chemin clavier.

Référence API

InputGroup

Le composant principal qui enveloppe les champs de saisie et les compléments.

PropTypeDefault
classNamestring
<InputGroup>
  <InputGroupInput />
  <InputGroupAddon />
</InputGroup>

InputGroupAddon

Affiche des icônes, du texte, des boutons ou d’autres contenus à côté des champs de saisie.

PropTypeDefault
align"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"
classNamestring
<InputGroupAddon align="inline-end">
  <SearchIcon />
</InputGroupAddon>

Pour <InputGroupInput />, utilisez l’alignement inline-start ou inline-end. Pour <InputGroupTextarea />, utilisez l’alignement block-start ou block-end.

Le composant InputGroupAddon peut contenir plusieurs composants InputGroupButton et plusieurs icônes.

<InputGroupAddon>
  <InputGroupButton>Button</InputGroupButton>
  <InputGroupButton>Button</InputGroupButton>
</InputGroupAddon>

InputGroupButton

Affiche des boutons à l’intérieur des groupes de champs.

PropTypeDefault
size"xs" | "icon-xs" | "sm" | "icon-sm""xs"
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""ghost"
classNamestring
<InputGroupButton>Button</InputGroupButton>
<InputGroupButton size="icon-xs" aria-label="Copy">
  <CopyIcon />
</InputGroupButton>

InputGroupInput

Remplace <Input /> lors de la création de groupes de champs. Ce composant applique déjà les styles du groupe de champs et utilise le data-slot="input-group-control" unifié pour la gestion de l’état de focus.

PropTypeDefault
classNamestring

Toutes les autres props sont transmises au composant <Input /> sous-jacent.

<InputGroup>
  <InputGroupInput placeholder="Enter text..." />
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
</InputGroup>

InputGroupTextarea

Remplace <Textarea /> lors de la création de groupes de champs. Ce composant applique déjà les styles du groupe de zone de texte et utilise le data-slot="input-group-control" unifié pour la gestion de l’état de focus.

PropTypeDefault
classNamestring

Toutes les autres props sont transmises au composant <Textarea /> sous-jacent.

<InputGroup>
  <InputGroupTextarea placeholder="Enter message..." />
  <InputGroupAddon align="block-end">
    <InputGroupButton>Send</InputGroupButton>
  </InputGroupAddon>
</InputGroup>