Skip to content
Gx
GitHub

Input Group

An input with an addon or a text prefix.

<inputgroup.InputGroup>
  <inputgroup.InputGroupAddon><inputgroup.InputGroupText>$</inputgroup.InputGroupText></inputgroup.InputGroupAddon>
  <inputgroup.InputGroupInput placeholder="0.00" attrs={gx.Attrs{{Key: "aria-label", Value: "Amount"}}} />
</inputgroup.InputGroup>

Installation

Run the command in the app module.

Terminal
gx add input-group

The command also installs button, icons, input and textarea.

The command writes these files.

File Path in the app
InputGroup.gx ui/input-group/InputGroup.gx
InputGroupAddon.gx ui/input-group/InputGroupAddon.gx
InputGroupInput.gx ui/input-group/InputGroupInput.gx
InputGroupText.gx ui/input-group/InputGroupText.gx
InputGroup.fixtures.go ui/input-group/InputGroup.fixtures.go
styles.go ui/input-group/styles.go
InputGroupAddon.fixtures.go ui/input-group/InputGroupAddon.fixtures.go
InputGroupInput.fixtures.go ui/input-group/InputGroupInput.fixtures.go
InputGroupText.fixtures.go ui/input-group/InputGroupText.fixtures.go
InputGroupButton.gx ui/input-group/InputGroupButton.gx
InputGroupButton.fixtures.go ui/input-group/InputGroupButton.fixtures.go
InputGroupTextarea.gx ui/input-group/InputGroupTextarea.gx
InputGroupTextarea.fixtures.go ui/input-group/InputGroupTextarea.fixtures.go

Install button, icons, input and textarea first.

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/input-group/InputGroup.gx
package inputgroup

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the group: one control and its addons.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div data-slot="input-group" role="group" class={gx.Cx("group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 h-9 min-w-0 has-[>textarea]:h-auto has-[>[data-align=inline-start]]:[&>input]:pl-2 has-[>[data-align=inline-end]]:[&>input]:pr-2 has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", p.Class)} {...p.Attrs}>{p.Children}</div>
ui/input-group/InputGroupAddon.gx
package inputgroup

props {
  // Align sets the position of the addon: InlineStart, InlineEnd, BlockStart or BlockEnd.
  Align    Align = InlineStart
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the addon, for example an icon, text or a button.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="group" data-slot="input-group-addon" data-align={p.align()} class={gx.Cx("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", alignClass[Align(p.align())], p.Class)} {...p.Attrs}>{p.Children}</div>
ui/input-group/InputGroupInput.gx
package inputgroup

import "github.com/alternayte/gx/registry/input"

props {
  // Id is the id of the root element. A label points at it.
  Id          string = ""
  // Type is the type attribute. Empty means "text".
  Type        string = "text"
  // Name is the name attribute. The form sends the value under this name.
  Name        string = ""
  // Value is the value attribute: the start value of the input.
  Value       string = ""
  // Placeholder is the placeholder attribute: the hint text of an empty control.
  Placeholder string = ""
  // Class adds classes to the root element.
  Class       string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<input type={p.inputType()} name={p.Name} value={p.Value} placeholder={p.Placeholder} data-slot="input-group-control" class={gx.Cx(input.Control, "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", p.Class)} {...p.attrs()} />
ui/input-group/InputGroupText.gx
package inputgroup

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the text, with or without an icon.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<span class={gx.Cx("flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", p.Class)} {...p.Attrs}>{p.Children}</span>
ui/input-group/InputGroup.fixtures.go
package inputgroup

import (
	"github.com/alternayte/gx"
	"github.com/alternayte/gx/registry/button"
	"github.com/alternayte/gx/registry/icons"
)

// name returns the accessible name of a fixture control.
func name(label string) gx.Attrs {
	return gx.Attrs{{Key: "aria-label", Value: label}}
}

var InputGroupFixtures = gx.Fixtures[InputGroupProps]{
	"LeadingText": {Children: gx.Frag(
		InputGroupAddon(InputGroupAddonProps{Children: InputGroupText(InputGroupTextProps{Children: gx.Text("$")})}),
		InputGroupInput(InputGroupInputProps{Placeholder: "0.00", Attrs: name("Amount")}),
	)},
	"TrailingText": {Children: gx.Frag(
		InputGroupInput(InputGroupInputProps{Placeholder: "example", Attrs: name("Domain")}),
		InputGroupAddon(InputGroupAddonProps{Align: InlineEnd, Children: InputGroupText(InputGroupTextProps{Children: gx.Text(".com")})}),
	)},
	"Button": {Children: gx.Frag(
		InputGroupInput(InputGroupInputProps{Placeholder: "Search", Attrs: name("Search")}),
		InputGroupAddon(InputGroupAddonProps{Align: InlineEnd, Children: InputGroupButton(InputGroupButtonProps{Children: gx.Text("Search")})}),
	)},
	"IconButton": {Children: gx.Frag(
		InputGroupInput(InputGroupInputProps{Value: "ada@example.com", Attrs: name("Email")}),
		InputGroupAddon(InputGroupAddonProps{Align: InlineEnd, Children: InputGroupButton(InputGroupButtonProps{
			Size:     IconXs,
			Attrs:    name("Clear"),
			Children: icons.X(icons.XProps{}),
		})}),
	)},
	"Invalid": {Children: gx.Frag(
		InputGroupAddon(InputGroupAddonProps{Children: icons.Info(icons.InfoProps{})}),
		InputGroupInput(InputGroupInputProps{Value: "ada@", Attrs: gx.Attrs{{Key: "aria-label", Value: "Email"}, {Key: "aria-invalid", Value: "true"}}}),
	)},
	"Textarea": {Children: gx.Frag(
		InputGroupTextarea(InputGroupTextareaProps{Placeholder: "Ask a question.", Attrs: name("Question")}),
		InputGroupAddon(InputGroupAddonProps{Align: BlockEnd, Children: gx.Frag(
			InputGroupText(InputGroupTextProps{Children: gx.Text("120 characters left")}),
			InputGroupButton(InputGroupButtonProps{Variant: button.Default, Size: Sm, Class: "ml-auto", Children: gx.Text("Send")}),
		)}),
	)},
	"BlockStart": {Children: gx.Frag(
		InputGroupAddon(InputGroupAddonProps{Align: BlockStart, Children: InputGroupText(InputGroupTextProps{Children: gx.Text("Amount")})}),
		InputGroupInput(InputGroupInputProps{Placeholder: "0.00", Attrs: name("Amount")}),
	)},
}
ui/input-group/styles.go
package inputgroup

import (
	"github.com/alternayte/gx"
	"github.com/alternayte/gx/registry/button"
)

// Align is the position of an addon inside an input group.
type Align string

// The alignments of inputgroup.InputGroupAddon.
const (
	InlineStart Align = "inline-start"
	InlineEnd   Align = "inline-end"
	BlockStart  Align = "block-start"
	BlockEnd    Align = "block-end"
)

var alignClass = gx.Enum[Align]{
	InlineStart: "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
	InlineEnd:   "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
	BlockStart:  "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
	BlockEnd:    "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
}

// align returns the data-align value; a zero value is InlineStart. The
// group reads this attribute to pad its control.
func (p InputGroupAddonProps) align() string {
	if p.Align == "" {
		return string(InlineStart)
	}
	return string(p.Align)
}

// Size is the height and padding of a button inside an input group.
type Size string

// The sizes of inputgroup.InputGroupButton.
const (
	Xs     Size = "xs"
	Sm     Size = "sm"
	IconXs Size = "icon-xs"
	IconSm Size = "icon-sm"
)

var sizeClass = gx.Enum[Size]{
	Xs:     "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
	Sm:     "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
	IconXs: "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
	IconSm: "size-8 p-0 has-[>svg]:p-0",
}

// buttonSize is the button size that each group size starts from: the
// group classes replace its height and padding.
var buttonSize = map[Size]button.Size{
	Xs:     button.Md,
	Sm:     button.Md,
	IconXs: button.Icon,
	IconSm: button.Icon,
}

// size returns the button size; a zero value is Xs.
func (p InputGroupButtonProps) size() Size {
	if p.Size == "" {
		return Xs
	}
	return p.Size
}

// variant returns the button variant; a zero value is Ghost.
func (p InputGroupButtonProps) variant() button.Variant {
	if p.Variant == "" {
		return button.Ghost
	}
	return p.Variant
}

// inputType returns the type of the input; a zero value is text.
func (p InputGroupInputProps) inputType() string {
	if p.Type == "" {
		return "text"
	}
	return p.Type
}

// attrs returns the id when the input has one, then the caller's
// attributes.
func (p InputGroupInputProps) attrs() gx.Attrs {
	if p.Id == "" {
		return p.Attrs
	}
	return append(gx.Attrs{{Key: "id", Value: p.Id}}, p.Attrs...)
}

// attrs returns the id when the textarea has one, then the caller's
// attributes.
func (p InputGroupTextareaProps) attrs() gx.Attrs {
	if p.Id == "" {
		return p.Attrs
	}
	return append(gx.Attrs{{Key: "id", Value: p.Id}}, p.Attrs...)
}
ui/input-group/InputGroupAddon.fixtures.go
package inputgroup

import "github.com/alternayte/gx"

var InputGroupAddonFixtures = gx.Fixtures[InputGroupAddonProps]{"Text": {Children: gx.Text("@")}}
ui/input-group/InputGroupInput.fixtures.go
package inputgroup

import "github.com/alternayte/gx"

var InputGroupInputFixtures = gx.Fixtures[InputGroupInputProps]{"Placeholder": {Placeholder: "Value", Attrs: name("Value")}}

// InputGroupInputWrap renders the input in a group, which draws its border
// and its focus ring.
func InputGroupInputWrap(n gx.Node) gx.Node {
	return InputGroup(InputGroupProps{Children: n})
}
ui/input-group/InputGroupText.fixtures.go
package inputgroup

import "github.com/alternayte/gx"

var InputGroupTextFixtures = gx.Fixtures[InputGroupTextProps]{"Text": {Children: gx.Text("Text")}}
ui/input-group/InputGroupButton.gx
package inputgroup

import "github.com/alternayte/gx/registry/button"

props {
  // Variant sets the visual style. It takes a button.Variant: Default, Secondary, Destructive,
  // Outline, Ghost or Link.
  Variant  button.Variant = button.Ghost
  // Size sets the height and padding: Xs, Sm, or IconXs or IconSm for a square button.
  Size     Size = Xs
  // Type is the type attribute: "button", "submit" or "reset".
  Type     string = "button"
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the button label.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<button.Button type={p.Type} variant={p.variant()} size={buttonSize[p.size()]} class={gx.Cx("flex items-center gap-2 text-sm shadow-none", sizeClass[p.size()], p.Class)} attrs={p.Attrs}>{p.Children}</button.Button>
ui/input-group/InputGroupButton.fixtures.go
package inputgroup

import (
	"github.com/alternayte/gx"
	"github.com/alternayte/gx/registry/icons"
)

var InputGroupButtonFixtures = gx.Fixtures[InputGroupButtonProps]{
	"ExtraSmall": {Children: gx.Text("Search")},
	"Small":      {Size: Sm, Children: gx.Text("Search")},
	"IconXs":     {Size: IconXs, Attrs: name("Clear"), Children: icons.X(icons.XProps{})},
	"IconSm":     {Size: IconSm, Attrs: name("Clear"), Children: icons.X(icons.XProps{})},
}
ui/input-group/InputGroupTextarea.gx
package inputgroup

import "github.com/alternayte/gx/registry/textarea"

props {
  // Id is the id of the root element. A label points at it.
  Id          string = ""
  // Name is the name attribute. The form sends the value under this name.
  Name        string = ""
  // Value is the start text of the textarea.
  Value       string = ""
  // Placeholder is the placeholder attribute: the hint text of an empty control.
  Placeholder string = ""
  // Class adds classes to the root element.
  Class       string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<textarea name={p.Name} placeholder={p.Placeholder} data-slot="input-group-control" class={gx.Cx(textarea.Control, "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", p.Class)} {...p.attrs()}>{p.Value}</textarea>
ui/input-group/InputGroupTextarea.fixtures.go
package inputgroup

import "github.com/alternayte/gx"

var InputGroupTextareaFixtures = gx.Fixtures[InputGroupTextareaProps]{
	"Placeholder": {Placeholder: "Ask a question.", Attrs: name("Question")},
}

// InputGroupTextareaWrap renders the textarea in a group, which draws its
// border and its focus ring.
func InputGroupTextareaWrap(n gx.Node) gx.Node {
	return InputGroup(InputGroupProps{Children: n})
}

The theme must define these tokens: --input, --ring, --muted-foreground, --destructive.

Usage

An input or a textarea with text, icons or buttons inside its border.

<inputgroup.InputGroup>
  <inputgroup.InputGroupAddon><icons.Info /></inputgroup.InputGroupAddon>
  <inputgroup.InputGroupInput name="q" placeholder="Search" />
  <inputgroup.InputGroupAddon align={inputgroup.InlineEnd}>
    <inputgroup.InputGroupButton>Search</inputgroup.InputGroupButton>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

<inputgroup.InputGroup>
  <inputgroup.InputGroupTextarea name="message" placeholder="Ask a question." />
  <inputgroup.InputGroupAddon align={inputgroup.BlockEnd}>
    <inputgroup.InputGroupText>120 characters left</inputgroup.InputGroupText>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

An addon has four positions: InlineStart, InlineEnd, BlockStart and BlockEnd. A block addon makes the group a column. InputGroupButton is a ghost button with the sizes Xs, Sm, IconXs and IconSm.

The group shows the focus ring of its control. It shows the error border when the control has aria-invalid="true". A click on an addon does not focus the control. Put the addon text in a <label for> when a click must focus it.

Examples

InputGroup: Leading text

<inputgroup.InputGroup>
  <inputgroup.InputGroupAddon><inputgroup.InputGroupText>$</inputgroup.InputGroupText></inputgroup.InputGroupAddon>
  <inputgroup.InputGroupInput placeholder="0.00" attrs={gx.Attrs{{Key: "aria-label", Value: "Amount"}}} />
</inputgroup.InputGroup>

InputGroup: Trailing text

<inputgroup.InputGroup>
  <inputgroup.InputGroupInput placeholder="example" attrs={gx.Attrs{{Key: "aria-label", Value: "Domain"}}} />
  <inputgroup.InputGroupAddon align={inputgroup.InlineEnd}>
    <inputgroup.InputGroupText>.com</inputgroup.InputGroupText>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

InputGroup: Button

<inputgroup.InputGroup>
  <inputgroup.InputGroupInput placeholder="Search" attrs={gx.Attrs{{Key: "aria-label", Value: "Search"}}} />
  <inputgroup.InputGroupAddon align={inputgroup.InlineEnd}>
    <inputgroup.InputGroupButton>Search</inputgroup.InputGroupButton>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

InputGroup: Icon button

<inputgroup.InputGroup>
  <inputgroup.InputGroupInput value="ada@example.com" attrs={gx.Attrs{{Key: "aria-label", Value: "Email"}}} />
  <inputgroup.InputGroupAddon align={inputgroup.InlineEnd}>
    <inputgroup.InputGroupButton size={inputgroup.IconXs} attrs={gx.Attrs{{Key: "aria-label", Value: "Clear"}}}>
      <icons.X />
    </inputgroup.InputGroupButton>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

InputGroup: Invalid

<inputgroup.InputGroup>
  <inputgroup.InputGroupAddon><icons.Info /></inputgroup.InputGroupAddon>
  <inputgroup.InputGroupInput value="ada@" attrs={gx.Attrs{{Key: "aria-label", Value: "Email"}, {Key: "aria-invalid", Value: "true"}}} />
</inputgroup.InputGroup>

InputGroup: Textarea

<inputgroup.InputGroup>
  <inputgroup.InputGroupTextarea placeholder="Ask a question." attrs={gx.Attrs{{Key: "aria-label", Value: "Question"}}} />
  <inputgroup.InputGroupAddon align={inputgroup.BlockEnd}>
    <inputgroup.InputGroupText>120 characters left</inputgroup.InputGroupText>
    <inputgroup.InputGroupButton variant={button.Default} size={inputgroup.Sm} class="ml-auto">
      Send
    </inputgroup.InputGroupButton>
  </inputgroup.InputGroupAddon>
</inputgroup.InputGroup>

InputGroup: Block start

<inputgroup.InputGroup>
  <inputgroup.InputGroupAddon align={inputgroup.BlockStart}>
    <inputgroup.InputGroupText>Amount</inputgroup.InputGroupText>
  </inputgroup.InputGroupAddon>
  <inputgroup.InputGroupInput placeholder="0.00" attrs={gx.Attrs{{Key: "aria-label", Value: "Amount"}}} />
</inputgroup.InputGroup>

InputGroupAddon: Text

<inputgroup.InputGroupAddon>@</inputgroup.InputGroupAddon>

InputGroupButton: Extra small

<inputgroup.InputGroupButton>Search</inputgroup.InputGroupButton>

InputGroupButton: Small

<inputgroup.InputGroupButton size={inputgroup.Sm}>Search</inputgroup.InputGroupButton>

InputGroupButton: Icon xs

<inputgroup.InputGroupButton size={inputgroup.IconXs} attrs={gx.Attrs{{Key: "aria-label", Value: "Clear"}}}>
  <icons.X />
</inputgroup.InputGroupButton>

InputGroupButton: Icon sm

<inputgroup.InputGroupButton size={inputgroup.IconSm} attrs={gx.Attrs{{Key: "aria-label", Value: "Clear"}}}>
  <icons.X />
</inputgroup.InputGroupButton>

InputGroupInput: Placeholder

<inputgroup.InputGroupInput placeholder="Value" attrs={gx.Attrs{{Key: "aria-label", Value: "Value"}}} />

InputGroupText: Text

<inputgroup.InputGroupText>Text</inputgroup.InputGroupText>

InputGroupTextarea: Placeholder

<inputgroup.InputGroupTextarea placeholder="Ask a question." attrs={gx.Attrs{{Key: "aria-label", Value: "Question"}}} />

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

inputgroup.InputGroup

Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the group: one control and its addons.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

inputgroup.InputGroupAddon

Prop Type Default Description
Align Align InlineStart Align sets the position of the addon: InlineStart, InlineEnd, BlockStart or BlockEnd.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the addon, for example an icon, text or a button.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

inputgroup.InputGroupButton

Prop Type Default Description
Variant button.Variant button.Ghost Variant sets the visual style. It takes a button.Variant: Default, Secondary, Destructive, Outline, Ghost or Link.
Size Size Xs Size sets the height and padding: Xs, Sm, or IconXs or IconSm for a square button.
Type string "button" Type is the type attribute: "button", "submit" or "reset".
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the button label.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

inputgroup.InputGroupInput

Prop Type Default Description
Id string "" Id is the id of the root element. A label points at it.
Type string "text" Type is the type attribute. Empty means "text".
Name string "" Name is the name attribute. The form sends the value under this name.
Value string "" Value is the value attribute: the start value of the input.
Placeholder string "" Placeholder is the placeholder attribute: the hint text of an empty control.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

inputgroup.InputGroupText

Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the text, with or without an icon.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

inputgroup.InputGroupTextarea

Prop Type Default Description
Id string "" Id is the id of the root element. A label points at it.
Name string "" Name is the name attribute. The form sends the value under this name.
Value string "" Value is the start text of the textarea.
Placeholder string "" Placeholder is the placeholder attribute: the hint text of an empty control.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Give the control an aria-label or a label. An addon is not a label.
  • Put a block addon with a textarea.

Don't

  • Do not put more than one control in a group.
  • Do not use an addon for an error message. Use a field error.

Keyboard

Key Action
Tab Moves focus to the control, then to each button of the group.
Enter, Space Activates the focused button.