@@ -95,6 +107,42 @@ export const Default = {
}
}
+const SIZES_MARKUP = `
+
+ https://
+ ${MIDDLE_INPUT}
+ .com
+
+
+ https://
+ ${MIDDLE_INPUT}
+ .com
+
+
+ https://
+ ${MIDDLE_INPUT}
+ .com
+
+
`
+
+/** @type {import('@storybook/vue3').StoryObj} */
+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 = `
https://
${MIDDLE_INPUT}
diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json
index ff45daca2..5b2dcbe8a 100644
--- a/packages/webkit/catalog.json
+++ b/packages/webkit/catalog.json
@@ -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",
@@ -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",
diff --git a/packages/webkit/src/components/inputs/input-group/input-group-addon/input-group-addon.vue b/packages/webkit/src/components/inputs/input-group/input-group-addon/input-group-addon.vue
index 4eec14b12..6c6200b74 100644
--- a/packages/webkit/src/components/inputs/input-group/input-group-addon/input-group-addon.vue
+++ b/packages/webkit/src/components/inputs/input-group/input-group-addon/input-group-addon.vue
@@ -16,7 +16,7 @@
diff --git a/packages/webkit/src/components/inputs/input-group/input-group.test.ts b/packages/webkit/src/components/inputs/input-group/input-group.test.ts
index c0666b686..b405f9de2 100644
--- a/packages/webkit/src/components/inputs/input-group/input-group.test.ts
+++ b/packages/webkit/src/components/inputs/input-group/input-group.test.ts
@@ -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 ; aria-label gives it an
@@ -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: `$${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, {
@@ -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')
diff --git a/packages/webkit/src/components/inputs/input-group/input-group.vue b/packages/webkit/src/components/inputs/input-group/input-group.vue
index 7002787d4..3e4df77e3 100644
--- a/packages/webkit/src/components/inputs/input-group/input-group.vue
+++ b/packages/webkit/src/components/inputs/input-group/input-group.vue
@@ -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. */
@@ -13,6 +17,7 @@
}
const props = withDefaults(defineProps(), {
+ size: 'medium',
invalid: false,
required: false,
disabled: false
@@ -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"
>