Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .specs/input-group.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ spec_version: 4
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3714-10802
node_id: 3714:10802
checksum: a084ea5340b76e0a5664fd895a69ce39d6b1679fa243ba3dd7a4c14b9af913bc
checksum: 2a6f539a2733213f73bb59cf27a14c1d6eb0a41016c1c6feaccbfcf217117668
created: 2026-07-01
last_updated: 2026-07-06
last_updated: 2026-07-22
---

# InputGroup — Component Spec
Expand Down Expand Up @@ -107,6 +107,7 @@ Root props:

| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | no | Size token; affects the root's height and inner addon padding. Heights: small=28px, medium=32px, large=40px. Matches the sibling input primitives (`InputText`, `Select`) — set the same `size` on child controls to keep them visually aligned. |
| `invalid` | `boolean` | `false` | no | Renders the error border and sets `aria-invalid="true"` on the root. |
| `required` | `boolean` | `false` | no | Renders the required (warning) border and sets `aria-required="true"` on the root. |
| `disabled` | `boolean` | `false` | no | Renders the disabled visual (muted fill, not-allowed cursor, no focus-within ring) and sets `aria-disabled="true"` on the root. Does not propagate to the child controls — each child is responsible for its own `disabled` attribute (mirrors how `FieldText` disables `InputText`). |
Expand All @@ -126,6 +127,8 @@ Root props:
## States

- Visual states on root: `default`, `hover`, `focus-within`, `invalid`, `required`, `disabled`
- `data-size` mirrors the `size` prop and drives the root height (`small`=`h-7`, `medium`=`h-8`, `large`=`h-10`)
- Only one focus indicator is visible when a descendant is focused: the group draws the single `focus-within` ring on its root, and the inner input primitives' own focus rings are suppressed via `[&_*]:focus-visible:!ring-0 [&_*]:focus-within:!ring-0` so `Button`, `Select.Trigger`, `InputText` wrappers, and raw `<input>` share the group's ring instead of stacking their own on top
- `data-invalid` mirrors the `invalid` prop; sets `aria-invalid="true"`
- `data-required` mirrors the `required` prop; sets `aria-required="true"`
- `data-disabled` mirrors the `disabled` prop; sets `aria-disabled="true"`
Expand Down Expand Up @@ -160,7 +163,9 @@ Root props:
| root text (disabled) | `var(--text-disabled)` |
| root border width | `border` (Tailwind utility) |
| root shape | `var(--shape-elements)` |
| root height | `h-8` (Tailwind utility) |
| root height (small) | `h-7` (Tailwind utility) |
| root height (medium) | `h-8` (Tailwind utility) |
| root height (large) | `h-10` (Tailwind utility) |
| focus ring | `var(--ring-color)` |
| focus ring offset | `var(--bg-canvas)` |
| child seam | `border-r border-[color:var(--border-default)]` (applied via `[&>*:not(:last-child)]:border-r`) |
Expand Down Expand Up @@ -188,6 +193,7 @@ Root props:
## Stories (Storybook)

- Default — root with a middle input, no addons or extra children.
- Sizes — the three sizes stacked (small=28px, medium=32px, large=40px).
- WithAddonLeft — `<InputGroup.Addon>https://</InputGroup.Addon>` + middle input.
- WithAddonRight — middle input + `<InputGroup.Addon>.com</InputGroup.Addon>`.
- BothAddons — addon + input + addon.
Expand All @@ -198,7 +204,7 @@ Root props:
- Required — `required=true` (warning border, always visible).
- Disabled — `disabled=true` (muted fill, not-allowed cursor, no focus ring).

Justification for ten stories (deviates from Default+Types+Sizes+state pattern): the component has no `kind` and no `size`, so `Types` and `Sizes` do not apply. Four addon-composition stories (Default, WithAddonLeft, WithAddonRight, BothAddons) document the static-content path. Three integration stories (WithButton, WithSelect, WithAll) prove Button/Select-as-children — the whole point of v4. Three state stories exercise `Invalid`, `Required`, and `Disabled` — the visual signals the component can emit.
Justification for eleven stories (deviates from Default+Types+Sizes+state pattern): the component has no `kind`, so `Types` does not apply. `Sizes` documents the three-height axis. Four addon-composition stories (Default, WithAddonLeft, WithAddonRight, BothAddons) document the static-content path. Three integration stories (WithButton, WithSelect, WithAll) prove Button/Select-as-children — the whole point of v4. Three state stories exercise `Invalid`, `Required`, and `Disabled` — the visual signals the component can emit.

## Constraints — DO NOT

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ const meta = {
}
},
argTypes: {
size: {
control: 'inline-radio',
options: ['small', 'medium', 'large'],
description:
'Size token; affects the root height (28/32/40px) and inner addon padding. Match the same `size` on child controls.',
table: {
category: 'props',
type: { summary: "'small' | 'medium' | 'large'" },
defaultValue: { summary: "'medium'" }
}
},
invalid: {
control: 'boolean',
description: 'Renders the error border and sets `aria-invalid="true"` on the root.',
Expand All @@ -64,6 +75,7 @@ const meta = {
}
},
args: {
size: 'medium',
invalid: false,
required: false,
disabled: false
Expand All @@ -77,7 +89,7 @@ const Template = (args) => ({
setup() {
return { props: args }
},
template: `<InputGroup :invalid="props.invalid" :required="props.required" :disabled="props.disabled">${MIDDLE_INPUT}</InputGroup>`
template: `<InputGroup :size="props.size" :invalid="props.invalid" :required="props.required" :disabled="props.disabled">${MIDDLE_INPUT}</InputGroup>`
})

const DEFAULT_MARKUP = `<InputGroup>
Expand All @@ -95,6 +107,42 @@ export const Default = {
}
}

const SIZES_MARKUP = `<div class="flex flex-col gap-4">
<InputGroup size="small">
<InputGroupAddon>https://</InputGroupAddon>
${MIDDLE_INPUT}
<InputGroupAddon>.com</InputGroupAddon>
</InputGroup>
<InputGroup size="medium">
<InputGroupAddon>https://</InputGroupAddon>
${MIDDLE_INPUT}
<InputGroupAddon>.com</InputGroupAddon>
</InputGroup>
<InputGroup size="large">
<InputGroupAddon>https://</InputGroupAddon>
${MIDDLE_INPUT}
<InputGroupAddon>.com</InputGroupAddon>
</InputGroup>
</div>`

/** @type {import('@storybook/vue3').StoryObj<typeof InputGroup>} */
export const Sizes = {
render: () => ({
components,
template: SIZES_MARKUP
}),
parameters: {
docs: {
controls: { disable: true },
description: {
story:
'The three sizes stacked (`small`=28px, `medium`=32px, `large`=40px). Match the same `size` on child input controls to keep the inner content vertically centered.'
},
source: { code: toSfc(IMPORTS, toSource(SIZES_MARKUP)) }
}
}
}

const WITH_ADDON_LEFT_MARKUP = `<InputGroup>
<InputGroupAddon>https://</InputGroupAddon>
${MIDDLE_INPUT}
Expand Down
14 changes: 14 additions & 0 deletions packages/webkit/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -5068,6 +5068,13 @@
"structure": "composition",
"status": "approved",
"props": [
{
"name": "size",
"type": "'small' | 'medium' | 'large'",
"default": "'medium'",
"required": "no",
"doc": "Size token; affects the root's height and inner addon padding. Heights: small=28px, medium=32px, large=40px. Matches the sibling input primitives (`InputText`, `Select`) — set the same `size` on child controls to keep them visually aligned."
},
{
"name": "invalid",
"type": "boolean",
Expand Down Expand Up @@ -5109,6 +5116,13 @@
"structure": "composition",
"status": "approved",
"props": [
{
"name": "size",
"type": "'small' | 'medium' | 'large'",
"default": "'medium'",
"required": "no",
"doc": "Size token; affects the root's height and inner addon padding. Heights: small=28px, medium=32px, large=40px. Matches the sibling input primitives (`InputText`, `Select`) — set the same `size` on child controls to keep them visually aligned."
},
{
"name": "invalid",
"type": "boolean",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<span
v-bind="$attrs"
:data-testid="testId"
class="flex items-center self-stretch shrink-0 bg-[var(--bg-canvas)] px-[var(--spacing-md)] text-label-sm text-[color:var(--text-muted)]"
class="flex items-center self-stretch shrink-0 bg-[var(--bg-canvas)] text-label-sm text-[color:var(--text-muted)] px-[var(--spacing-md)] [[data-size=small]_&]:px-[var(--spacing-sm)] [[data-size=large]_&]:px-[var(--spacing-md)]"
>
<slot />
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as stories from '../../../../../../apps/storybook/src/stories/component
import { expectNoA11yViolations } from '../../../test/axe'
import InputGroup, { InputGroupAddon } from './index'

const { Default, BothAddons, WithButton, WithSelect, Invalid, Required, Disabled } =
const { Default, Sizes, BothAddons, WithButton, WithSelect, Invalid, Required, Disabled } =
composeStories(stories)

// The middle input is a consumer-provided raw <input>; aria-label gives it an
Expand Down Expand Up @@ -98,6 +98,47 @@ describe('InputGroup', () => {
})
})

describe('size', () => {
it('defaults data-size to "medium" on the root', () => {
const { getByTestId } = render(InputGroup, { slots: { default: LABELLED_INPUT } })
expect(getByTestId('input-group').getAttribute('data-size')).toBe('medium')
})

it.each(['small', 'medium', 'large'] as const)(
'reflects size="%s" as data-size on the root',
(size) => {
const { getByTestId } = render(InputGroup, {
props: { size },
slots: { default: LABELLED_INPUT }
})
expect(getByTestId('input-group').getAttribute('data-size')).toBe(size)
}
)

it('propagates size to inner addons via CSS ancestor selector (data-size on root)', () => {
const { getByTestId } = render(InputGroup, {
props: { size: 'large' },
slots: {
default: `<span data-testid="addon-l">$</span>${LABELLED_INPUT}`
}
})
expect(getByTestId('input-group').getAttribute('data-size')).toBe('large')
})
})

describe('focus (single ring)', () => {
it('suppresses focus rings on all descendant elements so only the group ring shows', () => {
const { getByTestId } = render(InputGroup, {
slots: { default: LABELLED_INPUT }
})
const root = getByTestId('input-group')
const cls = root.getAttribute('class') ?? ''
expect(cls).toContain('[&_*]:focus-visible:!ring-0')
expect(cls).toContain('[&_*]:focus-within:!ring-0')
expect(cls).toContain('focus-within:ring-2')
})
})

describe('disabled', () => {
it('sets data-disabled and aria-disabled="true" on the root', () => {
const { getByTestId } = render(InputGroup, {
Expand Down Expand Up @@ -221,6 +262,13 @@ describe('InputGroup', () => {
expect(getByTestId('select-trigger__value').textContent?.trim()).toBe('BRL')
})

it('renders the Sizes story with one group per size, each carrying data-size', () => {
const { getAllByTestId } = render(Sizes)
const groups = getAllByTestId('input-group')
expect(groups).toHaveLength(3)
expect(groups.map((g) => g.getAttribute('data-size'))).toEqual(['small', 'medium', 'large'])
})

it('renders the Invalid story with data-invalid on the root', () => {
const { getByTestId } = render(Invalid)
expect(getByTestId('input-group').getAttribute('data-invalid')).toBe('true')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

defineOptions({ name: 'InputGroup', inheritAttrs: false })

export type InputGroupSize = 'small' | 'medium' | 'large'

export interface Props {
/** Size token; affects the root's height and inner addon padding. Heights: small=28px, medium=32px, large=40px. Matches the sibling input primitives — set the same size on child controls to keep them visually aligned. */
size?: InputGroupSize
/** Renders the error border and sets aria-invalid on the root. */
invalid?: boolean
/** Renders the required (warning) border and sets aria-required on the root. */
Expand All @@ -13,6 +17,7 @@
}

const props = withDefaults(defineProps<Props>(), {
size: 'medium',
invalid: false,
required: false,
disabled: false
Expand All @@ -32,13 +37,14 @@
v-bind="$attrs"
role="group"
:data-testid="testId"
:data-size="props.size"
:data-invalid="props.invalid || null"
:data-required="props.required || null"
:data-disabled="props.disabled || null"
:aria-invalid="props.invalid ? 'true' : undefined"
:aria-required="props.required ? 'true' : undefined"
:aria-disabled="props.disabled ? 'true' : undefined"
class="relative inline-flex items-center w-full h-8 overflow-hidden rounded-[var(--shape-elements)] border border-[var(--border-default)] bg-[var(--bg-surface)] transition-colors duration-150 ease-out motion-reduce:transition-none [&:not(:focus-within):not([data-invalid]):not([data-required]):not([data-disabled])]:hover:border-[var(--border-strong)] focus-within:outline-none focus-within:ring-2 focus-within:ring-[var(--ring-color)] focus-within:ring-offset-2 focus-within:ring-offset-[var(--bg-canvas)] data-[invalid]:border-[var(--danger-border)] data-[required]:border-[var(--warning-border)] data-[disabled]:bg-[var(--bg-disabled)] data-[disabled]:text-[var(--text-disabled)] data-[disabled]:cursor-not-allowed data-[disabled]:focus-within:ring-0 data-[disabled]:focus-within:ring-offset-0 [&_button]:!border-transparent [&_button]:!rounded-none [&_button]:focus-visible:!ring-0 [&_button]:focus-visible:!ring-offset-0 [&_a]:!border-transparent [&_a]:!rounded-none [&_a]:focus-visible:!ring-0 [&_a]:focus-visible:!ring-offset-0 [&_[role=combobox]]:!border-transparent [&_[role=combobox]]:!rounded-none [&_[data-mode]]:!w-auto [&>*:not(:last-child)]:!border-r [&>*:not(:last-child)]:!border-r-[color:var(--border-default)] [&>*:first-child]:rounded-l-[var(--shape-elements)] [&>*:not(:first-child)]:rounded-l-none [&>*:last-child]:rounded-r-[var(--shape-elements)] [&>*:not(:last-child)]:rounded-r-none"
class="relative inline-flex items-center w-full overflow-hidden rounded-[var(--shape-elements)] border border-[var(--border-default)] bg-[var(--bg-surface)] transition-colors duration-150 ease-out motion-reduce:transition-none data-[size=small]:h-7 data-[size=medium]:h-8 data-[size=large]:h-10 [&:not(:focus-within):not([data-invalid]):not([data-required]):not([data-disabled])]:hover:border-[var(--border-strong)] focus-within:outline-none focus-within:ring-2 focus-within:ring-[var(--ring-color)] focus-within:ring-offset-2 focus-within:ring-offset-[var(--bg-canvas)] data-[invalid]:border-[var(--danger-border)] data-[required]:border-[var(--warning-border)] data-[disabled]:bg-[var(--bg-disabled)] data-[disabled]:text-[var(--text-disabled)] data-[disabled]:cursor-not-allowed data-[disabled]:focus-within:ring-0 data-[disabled]:focus-within:ring-offset-0 [&_*]:focus-visible:!ring-0 [&_*]:focus-visible:!ring-offset-0 [&_*]:focus-within:!ring-0 [&_*]:focus-within:!ring-offset-0 [&_input]:!outline-none [&_button]:!border-transparent [&_button]:!rounded-none [&_a]:!border-transparent [&_a]:!rounded-none [&_[role=combobox]]:!border-transparent [&_[role=combobox]]:!rounded-none [&_[data-mode]]:!w-auto [&>*:not(:last-child)]:!border-r [&>*:not(:last-child)]:!border-r-[color:var(--border-default)] [&>*:first-child]:rounded-l-[var(--shape-elements)] [&>*:not(:first-child)]:rounded-l-none [&>*:last-child]:rounded-r-[var(--shape-elements)] [&>*:not(:last-child)]:rounded-r-none"
>
<slot />
</div>
Expand Down
Loading