diff --git a/.specs/input-group.md b/.specs/input-group.md index 43a508512..9da72112a 100644 --- a/.specs/input-group.md +++ b/.specs/input-group.md @@ -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 @@ -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`). | @@ -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 `` 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"` @@ -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`) | @@ -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 — `https://` + middle input. - WithAddonRight — middle input + `.com`. - BothAddons — addon + input + addon. @@ -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 diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-mobile.png new file mode 100644 index 000000000..252963c96 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-tablet.png new file mode 100644 index 000000000..8c710aa46 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--dark-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-desktop.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-desktop.png new file mode 100644 index 000000000..6b1a94b73 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-desktop.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-mobile.png new file mode 100644 index 000000000..636274a7f Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-tablet.png new file mode 100644 index 000000000..342b311c3 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes--light-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes.png new file mode 100644 index 000000000..f9f6f7e8e Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-inputs-inputgroup--sizes.png differ diff --git a/apps/storybook/src/stories/components/inputs/input-group/InputGroup.stories.js b/apps/storybook/src/stories/components/inputs/input-group/InputGroup.stories.js index ec4e2da7e..ec3f3c224 100644 --- a/apps/storybook/src/stories/components/inputs/input-group/InputGroup.stories.js +++ b/apps/storybook/src/stories/components/inputs/input-group/InputGroup.stories.js @@ -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.', @@ -64,6 +75,7 @@ const meta = { } }, args: { + size: 'medium', invalid: false, required: false, disabled: false @@ -77,7 +89,7 @@ const Template = (args) => ({ setup() { return { props: args } }, - template: `${MIDDLE_INPUT}` + template: `${MIDDLE_INPUT}` }) const DEFAULT_MARKUP = ` @@ -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" >