From 7b67099dd9afd38573f0718505ee16d2a0da4e49 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Wed, 18 Mar 2026 13:33:30 +0000 Subject: [PATCH 01/28] feat(InputGroup): new compound component with Addon, Suffix, and Button support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New InputGroup compound component for building inputs with icons, addons, inline suffixes, and action buttons. Features: - Field Integration — Accepts label, description, error, required, and labelTooltip props - Addons — Place icons or text before/after the input using align="start" or align="end" - Compact Button — Small button inside an Addon for secondary actions - Action Button — Full-height flush button as a direct child for primary actions - Inline Suffix — Text that flows seamlessly next to the typed value - Size Variants — xs, sm, base, lg sizes cascade to all children via context - Error State — Error flows through context; InputGroup.Input auto-sets aria-invalid - Disabled State — disabled prop disables all interactive children Sub-components: - InputGroup — Root container; provides context and accepts Field props - InputGroup.Input — Styled input; inherits size, disabled, error from context - InputGroup.Addon — Container for icons, text, or compact buttons - InputGroup.Button — Full-height button (direct child) or compact button (inside Addon) - InputGroup.Suffix — Inline text suffix with automatic width measurement Includes comprehensive documentation page with demos and unit tests. --- .changeset/feat-input-group-revamp.md | 57 ++ .../src/components/demos/InputGroupDemo.tsx | 352 ++++++++++++ .../src/pages/components/input-group.astro | 499 ++++++++++++++++++ packages/kumo/package.json | 4 + .../scripts/component-registry/discovery.ts | 1 + .../component-registry/sub-components.ts | 16 +- .../src/components/input-group/context.ts | 147 ++++++ .../kumo/src/components/input-group/index.ts | 8 + .../input-group/input-group-addon.tsx | 97 ++++ .../input-group/input-group-button.tsx | 56 ++ .../input-group/input-group-input.tsx | 75 +++ .../input-group/input-group-suffix.tsx | 44 ++ .../input-group/input-group.test.tsx | 243 +++++++++ .../components/input-group/input-group.tsx | 263 +++++++++ packages/kumo/src/components/input/index.ts | 10 +- .../kumo/src/components/input/input-group.tsx | 169 ------ .../kumo/src/components/input/input.test.tsx | 192 +++++++ packages/kumo/src/index.ts | 4 + packages/kumo/vite.config.ts | 4 + 19 files changed, 2069 insertions(+), 172 deletions(-) create mode 100644 .changeset/feat-input-group-revamp.md create mode 100644 packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx create mode 100644 packages/kumo-docs-astro/src/pages/components/input-group.astro create mode 100644 packages/kumo/src/components/input-group/context.ts create mode 100644 packages/kumo/src/components/input-group/index.ts create mode 100644 packages/kumo/src/components/input-group/input-group-addon.tsx create mode 100644 packages/kumo/src/components/input-group/input-group-button.tsx create mode 100644 packages/kumo/src/components/input-group/input-group-input.tsx create mode 100644 packages/kumo/src/components/input-group/input-group-suffix.tsx create mode 100644 packages/kumo/src/components/input-group/input-group.test.tsx create mode 100644 packages/kumo/src/components/input-group/input-group.tsx delete mode 100644 packages/kumo/src/components/input/input-group.tsx create mode 100644 packages/kumo/src/components/input/input.test.tsx diff --git a/.changeset/feat-input-group-revamp.md b/.changeset/feat-input-group-revamp.md new file mode 100644 index 0000000000..13dee9c82d --- /dev/null +++ b/.changeset/feat-input-group-revamp.md @@ -0,0 +1,57 @@ +--- +"@cloudflare/kumo": minor +"@cloudflare/kumo-docs-astro": patch +--- + +feat(InputGroup): new compound component with Addon, Suffix, and Button support + +New `InputGroup` compound component for building inputs with icons, addons, inline suffixes, and action buttons. + +## Features + +- Field Integration — InputGroup accepts `label`, `description`, `error`, `required`, and `labelTooltip` props directly; automatically wraps in Field when label is provided +- Addons — Place icons or text before/after the input using `align="start"` or `align="end"` +- Compact Button — Small button inside an Addon for secondary actions (copy, clear, toggle visibility) +- Action Button — Full-height flush button as a direct child for primary actions (submit, search) +- Inline Suffix — Text that flows seamlessly next to the typed value (e.g., `.workers.dev`); input width adjusts automatically as user types +- Size Variants — `xs`, `sm`, `base`, `lg` sizes cascade to all children via context +- Error State — Error flows through context; `InputGroup.Input` auto-sets `aria-invalid="true"` when error is present +- Disabled State — `disabled` prop disables all interactive children + +## Sub-components + +| Component | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | +| `InputGroup` | Root container; provides context and accepts Field props | +| `InputGroup.Input` | Styled input; inherits `size`, `disabled`, `error` from context | +| `InputGroup.Addon` | Container for icons, text, or compact buttons; `align="start"` (default) or `align="end"` | +| `InputGroup.Button` | Full-height button (direct child) or compact button (inside Addon) | +| `InputGroup.Suffix` | Inline text suffix with automatic width measurement | + +## Usage + +```tsx +// With Field props + + + @example.com + + +// Inline suffix + + + .workers.dev + + +// With action button + + + + Search + +``` diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx new file mode 100644 index 0000000000..94168f148b --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -0,0 +1,352 @@ +import { useRef, useState } from "react"; +import { InputGroup, Loader } from "@cloudflare/kumo"; +import { + MagnifyingGlassIcon, + CheckCircleIcon, + EyeIcon, + EyeSlashIcon, + LinkIcon, + TagIcon, + AirplaneTakeoffIcon, + InfoIcon, + SpinnerIcon, +} from "@phosphor-icons/react"; + +/** Common props to disable browser features in demo inputs */ +const demoInputProps = { + autoComplete: "off" as const, + autoCorrect: "off" as const, + autoCapitalize: "off" as const, + spellCheck: false, +}; + +/** Workers URL with inline suffix — validates on edit with spinner then success */ +export function InputGroupHeroDemo() { + return ; +} + +/** Icon addons in various positions: start-only, end-only, and both */ +export function InputGroupIconsDemo() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +/** Text labels and descriptions with Label and Description sub-components */ +export function InputGroupTextDemo() { + return ( +
+ + @ + + + + + + @example.com + + + + /api/ + + .json + +
+ ); +} + +/** Button variations: password toggle, inset button, and flush button */ +export function InputGroupButtonsDemo() { + const [show, setShow] = useState(false); + + return ( +
+ + + + setShow(!show)} + > + {show ? : } + + + + + + + + Apply + + + + + + Submit + +
+ ); +} + +/** Search input with keyboard shortcut hint */ +export function InputGroupKbdDemo() { + return ( + + + + + + + + ⌘K + + + + ); +} + +/** Loading variations: spinner at end, spinner at start, text + spinner at end */ +export function InputGroupLoadingDemo() { + return ( +
+ {/* Spinner at end */} + + + + + + + + {/* Spinner at start */} + + + + + + + + {/* Text + spinner at end */} + + + + Saving... + + + +
+ ); +} + +/** Inline suffix that follows typed text — shows spinner then success icon on edit */ +export function InputGroupSuffixDemo() { + return ; +} + +/** Shared Workers suffix input with validation spinner and success icon */ +function WorkersSuffixInput({ defaultValue }: { defaultValue: string }) { + const [value, setValue] = useState(defaultValue); + const [status, setStatus] = useState<"idle" | "loading" | "valid">("valid"); + const timerRef = useRef>(null); + + const handleChange = (e: React.ChangeEvent) => { + const next = e.target.value; + setValue(next); + + if (timerRef.current) clearTimeout(timerRef.current); + + if (next.length > 0) { + setStatus("loading"); + timerRef.current = setTimeout(() => setStatus("valid"), 1500); + } else { + setStatus("idle"); + } + }; + + return ( + + + .workers.dev + {status === "loading" && ( + + + + )} + {status === "valid" && ( + + + + )} + + ); +} + +/** All four sizes with a label */ +export function InputGroupSizesDemo() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +/** Various input states including error, disabled, and with description */ +export function InputGroupStatesDemo() { + const [show, setShow] = useState(false); + + return ( +
+ + + @example.com + + + + + + + + Search + + + + + + setShow(!show)} + > + {show ? : } + + + +
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/input-group.astro b/packages/kumo-docs-astro/src/pages/components/input-group.astro new file mode 100644 index 0000000000..e5f62d1db5 --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/input-group.astro @@ -0,0 +1,499 @@ +--- +import DocLayout from "../../layouts/DocLayout.astro"; +import Heading from "../../components/docs/Heading.astro"; +import ComponentSection from "../../components/docs/ComponentSection.astro"; +import ComponentExample from "../../components/docs/ComponentExample.astro"; +import CodeBlock from "../../components/docs/CodeBlock.astro"; +import PropsTable from "../../components/docs/PropsTable.astro"; +import { + InputGroupHeroDemo, + InputGroupIconsDemo, + InputGroupTextDemo, + InputGroupButtonsDemo, + InputGroupKbdDemo, + InputGroupLoadingDemo, + InputGroupSuffixDemo, + InputGroupSizesDemo, + InputGroupStatesDemo, +} from "../../components/demos/InputGroupDemo"; +--- + + + + + + + .workers.dev +`} + > + + + + + + + Installation + Barrel + + Granular + + + + + + Usage + + + + + + + ); +}`} + lang="tsx" + /> + + + + + Examples + +
+
+ Icon +

+ Use Addon to place icons at the start or end of the input. +

+ + {/* Start icon */} + + + + + + + + {/* End icon */} + + + + + + + + {/* Both sides */} + + + + + + + + + +`} + > + + +
+ +
+ Text +

+ Use Addon to place text prefixes or suffixes alongside the input. +

+ + {/* Start only */} + + @ + + + + {/* End only */} + + + @example.com + + + {/* Both sides */} + + /api/ + + .json + +`} + > + + +
+ +
+ Button +

+ Place InputGroup.Button inside an Addon for compact inset buttons, or directly as a child for a full-height flush button. +

+ + {/* Icon button inside Addon (compact, inset) */} + + + + {}} + > + {show ? : } + + + + + {/* Text button inside Addon (compact, inset) */} + + + + Apply + + + + {/* Button as direct child (full-height, flush) */} + + + Submit + +`} + > + + +
+ +
+ Kbd +

+ Place a keyboard shortcut hint inside an end Addon. +

+ + + + + + + + ⌘K + + +`} + > + + +
+ +
+ Loading +

+ Place a Loader inside an Addon at the start or end. Combine with a text span for a status label. +

+ + {/* Spinner at end */} + + + + + + + + {/* Spinner at start */} + + + + + + + + {/* Text + spinner at end */} + + + + Saving... + + + +`} + > + + +
+ +
+ Inline Suffix +

+ Suffix renders text that flows seamlessly next to the typed value — useful for domain inputs like .workers.dev. Truncates with ellipsis when space is limited. +

+ + + .workers.dev + + + +`} + > + + +
+ +
+ Sizes +

+ Four sizes: xs, sm, base (default), and lg. The size applies to the entire group. Use the label prop on InputGroup for built-in Field support. +

+ + {/* Extra small */} + + + + + + + + {/* Small */} + + + + + + + + {/* Base (default) */} + + + + + + + + {/* Large */} + + + + + + +`} + > + + +
+ +
+ States +

+ Various input states including error, disabled, and with description. Pass label, error, and description props directly to InputGroup. +

+ + {/* Error state */} + + + @example.com + + + {/* Disabled */} + + + + + + Search + + + {/* With description and tooltip */} + + + + {}} + > + {show ? : } + + + +`} + > + + +
+
+
+ + + + API Reference + +
+
+

InputGroup

+

+ The root container that provides context to all child components. Accepts Field props + (label, + description, + error) and wraps content in a Field when label is provided. +

+ +
+ +
+

InputGroup.Input

+

+ The text input element. Inherits size, + disabled, and + error from InputGroup context. + Accepts all standard input attributes except Field-related props which are handled by the parent. +

+ +
+ +
+

InputGroup.Addon

+

+ Container for icons, text, or compact buttons positioned at the start or end of the input. +

+ +
+ +
+

InputGroup.Suffix

+

+ Inline text that flows seamlessly next to the typed value (e.g., .workers.dev). + The input width adjusts automatically as the user types. +

+ +
+
+ +

Validation Error Types

+

+ When using error as + an object, the match + property corresponds to HTML5 ValidityState values: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MatchDescription
valueMissingRequired field is empty
typeMismatchValue doesn't match type (e.g., invalid email)
patternMismatchValue doesn't match pattern attribute
tooShortValue shorter than minLength
tooLongValue longer than maxLength
rangeUnderflowValue less than min
rangeOverflowValue greater than max
trueAlways show error (for server-side validation)
+
+
+ + + + Accessibility +
+
+

Label Requirement

+

InputGroup requires an accessible name via one of:

+
    +
  • + label prop on InputGroup (renders a visible label with built-in Field support) +
  • +
  • + aria-label on InputGroup.Input for inputs without a visible label +
  • +
  • + aria-labelledby on InputGroup.Input for custom label association +
  • +
+

+ Missing accessible names trigger console warnings in development. +

+
+
+

Group Role

+

+ InputGroup automatically renders with role="group", which semantically associates the input with its addons for assistive technologies. +

+
+
+
+
diff --git a/packages/kumo/package.json b/packages/kumo/package.json index b834140dd3..74c5beb164 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -84,6 +84,10 @@ "types": "./dist/src/components/input/index.d.ts", "import": "./dist/components/input.js" }, + "./components/input-group": { + "types": "./dist/src/components/input-group/index.d.ts", + "import": "./dist/components/input-group.js" + }, "./components/layer-card": { "types": "./dist/src/components/layer-card/index.d.ts", "import": "./dist/components/layer-card.js" diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts index c39b1c0c07..f375c27135 100644 --- a/packages/kumo/scripts/component-registry/discovery.ts +++ b/packages/kumo/scripts/component-registry/discovery.ts @@ -50,6 +50,7 @@ export const CATEGORY_MAP: Record = { "date-range-picker": "Input", field: "Input", input: "Input", + "input-group": "Input", radio: "Input", select: "Input", switch: "Input", diff --git a/packages/kumo/scripts/component-registry/sub-components.ts b/packages/kumo/scripts/component-registry/sub-components.ts index 3925a22a61..371e70ba17 100644 --- a/packages/kumo/scripts/component-registry/sub-components.ts +++ b/packages/kumo/scripts/component-registry/sub-components.ts @@ -5,7 +5,8 @@ * like Dialog.Root, Dialog.Trigger, etc. */ -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; import type { SubComponentConfig, PropSchema } from "./types.js"; import { extractBalancedBraces } from "./utils.js"; import { shouldSkipProp } from "./props-filter.js"; @@ -184,10 +185,21 @@ export function extractSubComponentProps( } try { - const content = readFileSync(filePath, "utf-8"); + let content = readFileSync(filePath, "utf-8"); const funcName = subComponent.name; const props: Record = {}; + // If the sub-component function isn't in the main file (e.g. split into + // separate files), read all sibling .ts/.tsx files in the same directory. + const funcPattern = new RegExp(`(?:function|const)\\s+${funcName}\\b`); + if (!funcPattern.test(content)) { + const dir = dirname(filePath); + const siblings = readdirSync(dir) + .filter((f) => /\.(tsx?|ts)$/.test(f) && join(dir, f) !== filePath) + .map((f) => readFileSync(join(dir, f), "utf-8")); + content = [content, ...siblings].join("\n"); + } + // Pattern 1: Inline object type in function signature // Matches: function Foo({ ... }: { prop: Type }) or ({ ... }: PropsWithChildren<{ prop: Type }>) // Also matches arrow functions: const Foo = ({ ... }: { prop: Type }) => diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts new file mode 100644 index 0000000000..37b3fe8146 --- /dev/null +++ b/packages/kumo/src/components/input-group/context.ts @@ -0,0 +1,147 @@ +import { createContext } from "react"; +import type { KumoInputSize } from "../input/input"; +import type { FieldProps } from "../field/field"; + +// Spacing model +// +// Each element type has a fixed outer padding. The container uses has-[] CSS +// to reduce the input's padding on sides that touch an addon. +// +// Input outer: px-3 (12px base) — full padding when at the edge +// Input seam: pl-2 / pr-2 (8px base) — applied by container has-[] +// Addon outer: px-2 (8px base) — on the container-edge side +// Addon seam: nothing — input owns the gap entirely +// +// has-[] rules on the container override [&_input]:pl-{seam} when a start +// addon is present, and [&_input]:pr-{seam} when an end addon is present. + +export interface InputGroupSizeTokens { + /** Full outer padding — matches standalone Input (e.g. px-3). */ + inputOuter: string; + /** Outer padding for icon/text Addon at the container edge. */ + addonOuter: string; + /** pl- for ghost measurement span (matches inputOuter left). */ + ghostPad: string; + /** pr- for suffix when no end addon. */ + suffixPad: string; + /** pr- for suffix when end addon present (reserves icon space). */ + suffixReserve: string; + fontSize: string; + /** Icon size in px. */ + iconSize: number; +} + +export const INPUT_GROUP_SIZE: Record = { + xs: { + inputOuter: "px-1.5", + addonOuter: "px-1.5", + ghostPad: "pl-1.5", + suffixPad: "pr-1.5", + suffixReserve: "pr-6", + fontSize: "text-xs", + iconSize: 10, + }, + sm: { + inputOuter: "px-2", + addonOuter: "px-1.5", + ghostPad: "pl-2", + suffixPad: "pr-2", + suffixReserve: "pr-7", + fontSize: "text-xs", + iconSize: 13, + }, + base: { + inputOuter: "px-3", + addonOuter: "px-2", + ghostPad: "pl-3", + suffixPad: "pr-3", + suffixReserve: "pr-9", + fontSize: "text-base", + iconSize: 18, + }, + lg: { + inputOuter: "px-4", + addonOuter: "px-2.5", + ghostPad: "pl-4", + suffixPad: "pr-4", + suffixReserve: "pr-11", + fontSize: "text-base", + iconSize: 20, + }, +}; + +// Build the has-[] container classes that reduce input padding when addons +// are present. These are static strings so Tailwind JIT can detect them. +export const INPUT_GROUP_HAS_CLASSES: Record = { + xs: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1", + "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1", + "has-[[data-slot=input-group-button]]:[&_input]:pr-1", + ].join(" "), + sm: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1.5", + "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1.5", + "has-[[data-slot=input-group-button]]:[&_input]:pr-1.5", + ].join(" "), + base: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2", + "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2", + "has-[[data-slot=input-group-button]]:[&_input]:pr-2", + ].join(" "), + lg: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2.5", + "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2.5", + "has-[[data-slot=input-group-button]]:[&_input]:pr-2.5", + ].join(" "), +}; + +export const MIN_INPUT_WIDTH = 1; + +// Derive directional padding from a symmetric "px-N" token. +export function pl(px: string): string { + return px.replace("px-", "pl-"); +} +export function pr(px: string): string { + return px.replace("px-", "pr-"); +} + +// Context + +export interface InputGroupRootProps + extends Partial< + Pick< + FieldProps, + "label" | "description" | "error" | "required" | "labelTooltip" + > + > { + className?: string; + size?: KumoInputSize | undefined; + disabled?: boolean; + /** @internal */ + focusMode?: "container" | "individual"; +} + +export interface InputGroupContextValue + extends Omit< + InputGroupRootProps, + "focusMode" | "label" | "description" | "required" | "labelTooltip" + > { + focusMode: "container" | "individual"; + inputId: string; + hasStartAddon: boolean; + hasEndAddon: boolean; + hasSuffix: boolean; + insideAddon: boolean; + inputValue: string; + setInputValue: (value: string) => void; + registerAddon: (align: "start" | "end") => void; + unregisterAddon: (align: "start" | "end") => void; + registerInline: () => void; + unregisterInline: () => void; + disabled: boolean; + error?: FieldProps["error"]; +} + +export const InputGroupContext = createContext( + null, +); diff --git a/packages/kumo/src/components/input-group/index.ts b/packages/kumo/src/components/input-group/index.ts new file mode 100644 index 0000000000..ad021579d0 --- /dev/null +++ b/packages/kumo/src/components/input-group/index.ts @@ -0,0 +1,8 @@ +export { + InputGroup, + type InputGroupRootProps, + type InputGroupInputProps, + type InputGroupButtonProps, + type InputGroupAddonProps, + type InputGroupSuffixProps, +} from "./input-group"; diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx new file mode 100644 index 0000000000..27ee9c621a --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-addon.tsx @@ -0,0 +1,97 @@ +import { + Children, + cloneElement, + useContext, + useEffect, + useMemo, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { cn } from "../../utils/cn"; +import { InputGroupContext, INPUT_GROUP_SIZE, pl, pr } from "./context"; +import { Button } from "./input-group-button"; + +export interface InputGroupAddonProps { + /** Position relative to the input. @default "start" */ + align?: "start" | "end"; + /** Additional CSS classes. */ + className?: string; + /** Addon content: icons, buttons, spinners, text. */ + children?: ReactNode; +} + +/** + * Container for icons, text, or compact buttons positioned at the start or end + * of the input. Automatically sizes icon children to match the input size. + */ +export function Addon({ + align = "start", + className, + children, +}: InputGroupAddonProps) { + const context = useContext(InputGroupContext); + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + const hasInline = context?.hasSuffix; + + const registerAddon = context?.registerAddon; + const unregisterAddon = context?.unregisterAddon; + + useEffect(() => { + registerAddon?.(align); + return () => unregisterAddon?.(align); + }, [align, registerAddon, unregisterAddon]); + + const addonContext = useMemo( + () => (context ? { ...context, insideAddon: true } : null), + [context], + ); + + // Inject size into direct icon children that don't already have one set. + // Skips buttons (which have their own size handling) and non-element nodes. + const sizedChildren = Children.map(children, (child) => { + if (!isValidElement(child)) return child; + const props = child.props as { size?: unknown }; + if (props.size !== undefined) return child; + if (child.type === "button" || child.type === Button) return child; + return cloneElement(child as ReactElement<{ size?: number }>, { + size: tokens.iconSize, + }); + }); + + // In inline mode (Suffix present), addons overlay as absolute elements + // since the grid layout is reserved for the input + suffix measurement. + // In standard flex mode, addons are flow-based flex items. + return ( + +
+ {sizedChildren} +
+
+ ); +} +Addon.displayName = "InputGroup.Addon"; diff --git a/packages/kumo/src/components/input-group/input-group-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx new file mode 100644 index 0000000000..6b4e15ef60 --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-button.tsx @@ -0,0 +1,56 @@ +import { useContext, type PropsWithChildren } from "react"; +import { cn } from "../../utils/cn"; +import { type ButtonProps, Button as ButtonExternal } from "../button/button"; +import { InputGroupContext } from "./context"; + +export type InputGroupButtonProps = ButtonProps; + +/** + * Button that renders differently based on placement: + * - Inside Addon: compact button for secondary actions (toggle, copy) + * - Direct child: full-height flush button for primary actions (search, submit) + */ +export function Button({ + children, + className, + size, + ...props +}: PropsWithChildren) { + const context = useContext(InputGroupContext); + + const isInsideAddon = context?.insideAddon ?? false; + + if (isInsideAddon) { + return ( + + {children} + + ); + } + + const isIndividualFocus = context?.focusMode === "individual"; + + return ( + + {children} + + ); +} +Button.displayName = "InputGroup.Button"; diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx new file mode 100644 index 0000000000..3710f02e22 --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -0,0 +1,75 @@ +import { useCallback, useContext, useLayoutEffect } from "react"; +import type { ChangeEvent } from "react"; +import { cn } from "../../utils/cn"; +import { Input as InputExternal, type InputProps } from "../input/input"; +import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; + +/** Props for InputGroup.Input — omits Field props since InputGroup handles them. */ +export type InputGroupInputProps = Omit< + InputProps, + "label" | "labelTooltip" | "description" | "error" | "size" +>; + +/** + * Text input that inherits size, disabled, and error state from InputGroup context. + * Automatically sets `aria-invalid` when parent has an error. + */ +export function Input(props: InputGroupInputProps) { + const context = useContext(InputGroupContext); + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + + // Track input value in context so Suffix can measure it + const handleChange = useCallback( + ( + e: ChangeEvent & { + preventBaseUIHandler: () => void; + }, + ) => { + context?.setInputValue(e.target.value); + props.onChange?.(e); + }, + [context?.setInputValue, props.onChange], + ); + + // Sync controlled/default value into context before paint + // so the Suffix ghost measurement is accurate on first render. + useLayoutEffect(() => { + if (props.value !== undefined) { + context?.setInputValue(String(props.value)); + } else if (props.defaultValue !== undefined) { + context?.setInputValue(String(props.defaultValue)); + } + }, [props.value, props.defaultValue, context?.setInputValue]); + + // Auto-set aria-invalid when error is present in context + const hasError = Boolean(context?.error); + + return ( + + ); +} +Input.displayName = "InputGroup.Input"; diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx new file mode 100644 index 0000000000..5c74ea305f --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx @@ -0,0 +1,44 @@ +import { useContext, useEffect, type ReactNode } from "react"; +import { cn } from "../../utils/cn"; +import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; + +export interface InputGroupSuffixProps { + /** Additional CSS classes. */ + className?: string; + /** Suffix content (e.g., ".workers.dev"). */ + children?: ReactNode; +} + +/** + * Inline suffix that flows seamlessly next to the typed input value. + * Input width adjusts automatically as the user types. + */ +export function Suffix({ className, children }: InputGroupSuffixProps) { + const context = useContext(InputGroupContext); + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + + const registerInline = context?.registerInline; + const unregisterInline = context?.unregisterInline; + + useEffect(() => { + registerInline?.(); + return () => unregisterInline?.(); + }, [registerInline, unregisterInline]); + + return ( +
+ {children} +
+ ); +} +Suffix.displayName = "InputGroup.Suffix"; diff --git a/packages/kumo/src/components/input-group/input-group.test.tsx b/packages/kumo/src/components/input-group/input-group.test.tsx new file mode 100644 index 0000000000..a682d3a77d --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InputGroup } from "./input-group"; + +describe("InputGroup", () => { + describe("rendering", () => { + it("renders input with addon", () => { + render( + + https:// + + , + ); + expect(screen.getByText("https://")).toBeTruthy(); + expect(screen.getByPlaceholderText("example.com")).toBeTruthy(); + }); + + it("renders input with button", () => { + render( + + + Go + , + ); + expect(screen.getByPlaceholderText("Search...")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Go" })).toBeTruthy(); + }); + + it("renders input with suffix", () => { + render( + + + .workers.dev + , + ); + expect(screen.getByPlaceholderText("my-worker")).toBeTruthy(); + expect(screen.getByText(".workers.dev")).toBeTruthy(); + }); + + it("renders all sub-components together", () => { + render( + + $ + + USD + Pay + , + ); + expect(screen.getByText("$")).toBeTruthy(); + expect(screen.getByPlaceholderText("0.00")).toBeTruthy(); + expect(screen.getByText("USD")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Pay" })).toBeTruthy(); + }); + }); + + describe("addon positioning", () => { + it("places start addon before input in DOM order", () => { + render( + + Start + + , + ); + const addon = screen.getByText("Start"); + const input = screen.getByRole("textbox"); + // Addon should come before input in document order + expect( + addon.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("places end addon after input in DOM order", () => { + render( + + + End + , + ); + const addon = screen.getByText("End"); + const input = screen.getByRole("textbox"); + // Input should come before addon + expect( + input.compareDocumentPosition(addon) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + }); + + describe("user interactions", () => { + it("allows typing in input", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = screen.getByRole("textbox") as HTMLInputElement; + await user.type(input, "hello"); + expect(input.value).toBe("hello"); + }); + + it("calls onChange when typing", async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + + , + ); + + const input = screen.getByRole("textbox"); + await user.type(input, "hi"); + expect(handleChange).toHaveBeenCalled(); + }); + + it("calls onClick when button is clicked", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + + + Search + , + ); + + await user.click(screen.getByRole("button", { name: "Search" })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("calls onClick on compact button inside addon", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + + + + + 👁 + + + , + ); + + await user.click(screen.getByRole("button", { name: "Toggle" })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("focuses input when clicking on container", async () => { + const user = userEvent.setup(); + const { container } = render( + + Label + + , + ); + + const group = container.firstElementChild as HTMLElement; + await user.click(group); + expect(document.activeElement).toBe(screen.getByRole("textbox")); + }); + }); + + describe("disabled state", () => { + it("disables input when group is disabled", () => { + render( + + + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(input.disabled).toBe(true); + }); + + it("prevents interaction when disabled", async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + + , + ); + + const input = screen.getByRole("textbox"); + await user.type(input, "hello"); + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + describe("size variants", () => { + it("applies size to input", () => { + const { rerender } = render( + + + , + ); + + // Just verify it renders without error at different sizes + expect(screen.getByRole("textbox")).toBeTruthy(); + + rerender( + + + , + ); + expect(screen.getByRole("textbox")).toBeTruthy(); + }); + }); + + describe("accessibility", () => { + it("input has accessible name via aria-label", () => { + render( + + + , + ); + expect( + screen.getByRole("textbox", { name: "Email address" }), + ).toBeTruthy(); + }); + + it("button inside addon remains accessible", () => { + render( + + + + × + + , + ); + expect(screen.getByRole("button", { name: "Clear" })).toBeTruthy(); + }); + + it("group has role='group'", () => { + render( + + + , + ); + expect(screen.getByRole("group")).toBeTruthy(); + }); + }); +}); diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx new file mode 100644 index 0000000000..32e742829c --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -0,0 +1,263 @@ +import { + forwardRef, + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, +} from "react"; +import { cn } from "../../utils/cn"; +import { inputVariants } from "../input/input"; +import { Field } from "../field/field"; +import { + InputGroupContext, + INPUT_GROUP_SIZE, + INPUT_GROUP_HAS_CLASSES, + MIN_INPUT_WIDTH, + type InputGroupRootProps, +} from "./context"; +import { Input } from "./input-group-input"; +import { Button } from "./input-group-button"; +import { Addon } from "./input-group-addon"; +import { Suffix } from "./input-group-suffix"; + +export { type InputGroupRootProps } from "./context"; +export { type InputGroupInputProps } from "./input-group-input"; +export { type InputGroupButtonProps } from "./input-group-button"; +export { type InputGroupAddonProps } from "./input-group-addon"; +export { type InputGroupSuffixProps } from "./input-group-suffix"; + +export const KUMO_INPUT_GROUP_VARIANTS = {} as const; + +export const KUMO_INPUT_GROUP_DEFAULT_VARIANTS = {} as const; + +/** + * Compound input component for building inputs with icons, addons, inline + * suffixes, and action buttons. Accepts Field props and wraps content in + * Field when label is provided. + * + * @example + * ```tsx + * + * + * + * + * ``` + * + * @example + * ```tsx + * + * + * .workers.dev + * + * ``` + */ +const Root = forwardRef>( + ( + { + size = "base", + children, + className, + disabled = false, + focusMode = "container", + label, + description, + error, + required, + labelTooltip, + }, + forwardedRef, + ) => { + const inputId = useId(); + const localRef = useRef(null); + + // Merge forwarded ref with local ref + const ref = useCallback( + (node: HTMLDivElement | null) => { + localRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + const [addons, setAddons] = useState<{ + start: number; + end: number; + }>({ start: 0, end: 0 }); + const [hasSuffix, setHasSuffix] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const registerAddon = useCallback((align: "start" | "end") => { + setAddons((prev) => ({ ...prev, [align]: prev[align] + 1 })); + }, []); + + const unregisterAddon = useCallback((align: "start" | "end") => { + setAddons((prev) => ({ + ...prev, + [align]: Math.max(0, prev[align] - 1), + })); + }, []); + + const registerInline = useCallback(() => setHasSuffix(true), []); + const unregisterInline = useCallback(() => setHasSuffix(false), []); + + const contextValue = useMemo( + () => ({ + size, + focusMode, + inputId, + disabled, + error, + hasStartAddon: addons.start > 0, + hasEndAddon: addons.end > 0, + hasSuffix, + insideAddon: false, + inputValue, + setInputValue, + registerAddon, + unregisterAddon, + registerInline, + unregisterInline, + }), + [ + size, + focusMode, + inputId, + disabled, + error, + addons.start, + addons.end, + hasSuffix, + inputValue, + registerAddon, + unregisterAddon, + registerInline, + unregisterInline, + ], + ); + + const tokens = INPUT_GROUP_SIZE[size]; + const ghostRef = useRef(null); + + // Focus input when clicking empty space in the container. + // Attached via useEffect to keep the JSX clean for a11y linters. + useEffect(() => { + const el = localRef.current; + if (!el) return; + + const handleMouseDown = (e: MouseEvent) => { + if (disabled) return; + if ( + (e.target as HTMLElement).closest( + "button, input, textarea, select, a", + ) + ) + return; + setTimeout(() => document.getElementById(inputId)?.focus(), 0); + }; + + el.addEventListener("mousedown", handleMouseDown); + return () => el.removeEventListener("mousedown", handleMouseDown); + }, [disabled, inputId]); + + // Measure ghost and update input width when suffix is present. + useLayoutEffect(() => { + if (!hasSuffix) return; + if (!inputId || !ghostRef.current) return; + + const input = document.getElementById(inputId) as HTMLInputElement | null; + if (!input) return; + + // Copy letter-spacing from the actual input so the ghost measurement + // matches exactly. + ghostRef.current.style.letterSpacing = + window.getComputedStyle(input).letterSpacing; + + const value = inputValue !== "" ? inputValue : input.value; + + if (value) { + ghostRef.current.textContent = value; + const measuredWidth = ghostRef.current.offsetWidth + 1; + const width = Math.max(measuredWidth, MIN_INPUT_WIDTH); + input.style.width = `${width}px`; + input.style.maxWidth = "100%"; + } else { + input.style.width = `${MIN_INPUT_WIDTH}px`; + input.style.maxWidth = ""; + } + }, [inputId, inputValue, hasSuffix]); + + const container = ( + + {/* Ghost element for suffix width measurement */} + {hasSuffix && ( +
+
+ )} +
+ {children} +
+
+ ); + + if (label) { + return ( + + {container} + + ); + } + + return container; + }, +); +Root.displayName = "InputGroup"; + +export const InputGroup = Object.assign(Root, { + Input, + Button, + Addon, + Suffix, +}); diff --git a/packages/kumo/src/components/input/index.ts b/packages/kumo/src/components/input/index.ts index a111138ad1..8201d351e9 100644 --- a/packages/kumo/src/components/input/index.ts +++ b/packages/kumo/src/components/input/index.ts @@ -1,3 +1,11 @@ export { Input, inputVariants, type InputProps } from "./input"; export { InputArea, Textarea, type InputAreaProps } from "./input-area"; -export { InputGroup } from "./input-group"; +// Re-export InputGroup from its own directory for backwards compatibility +export { + InputGroup, + type InputGroupRootProps, + type InputGroupInputProps, + type InputGroupButtonProps, + type InputGroupAddonProps, + type InputGroupSuffixProps, +} from "../input-group"; diff --git a/packages/kumo/src/components/input/input-group.tsx b/packages/kumo/src/components/input/input-group.tsx deleted file mode 100644 index 10cd8ee2ac..0000000000 --- a/packages/kumo/src/components/input/input-group.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { type PropsWithChildren, useContext } from "react"; -import * as React from "react"; -import { cn } from "../../utils/cn"; -import { - Input as InputExternal, - type InputProps, - inputVariants, -} from "./input"; -import { type ButtonProps, Button as ButtonExternal } from "../button/button"; - -export const KUMO_INPUT_GROUP_VARIANTS = { - focusMode: { - container: { - classes: "", - description: "Focus indicator on container (default behavior)", - }, - individual: { - classes: "", - description: "Focus indicators on individual elements", - }, - }, -} as const; - -export const KUMO_INPUT_GROUP_DEFAULT_VARIANTS = { - focusMode: "container", -} as const; - -export type KumoInputGroupFocusMode = - keyof typeof KUMO_INPUT_GROUP_VARIANTS.focusMode; - -export interface KumoInputGroupVariantsProps { - focusMode?: KumoInputGroupFocusMode; -} - -interface InputGroupRootProps extends KumoInputGroupVariantsProps { - className?: string; - size?: "xs" | "sm" | "base" | "lg" | undefined; -} - -interface InputGroupContextValue extends InputGroupRootProps { - inputId: string; - descriptionId: string; -} - -const InputGroupContext = React.createContext( - null, -); - -function Root({ - size, - children, - className, - focusMode = KUMO_INPUT_GROUP_DEFAULT_VARIANTS.focusMode, -}: PropsWithChildren) { - const inputId = React.useId(); - const descriptionId = React.useId(); - const contextValue = React.useMemo( - () => ({ size, inputId, descriptionId, focusMode }), - [size, inputId, descriptionId, focusMode], - ); - - const isIndividualFocus = focusMode === "individual"; - - return ( - -
- {children} -
-
- ); -} - -function Label({ children }: PropsWithChildren<{}>) { - const context = useContext(InputGroupContext); - const isIndividualFocus = context?.focusMode === "individual"; - - return ( - - ); -} - -function Input(props: InputProps) { - const context = useContext(InputGroupContext); - const isIndividualFocus = context?.focusMode === "individual"; - - return ( - - ); -} - -function Description({ children }: PropsWithChildren<{}>) { - const context = useContext(InputGroupContext); - const isIndividualFocus = context?.focusMode === "individual"; - - return ( - - {children} - - ); -} - -function Button({ - children, - className, - ...props -}: PropsWithChildren) { - const context = useContext(InputGroupContext); - const isIndividualFocus = context?.focusMode === "individual"; - - return ( - - {children} - - ); -} - -export const InputGroup = Object.assign(Root, { - Label, - Input, - Button, - Description, -}); diff --git a/packages/kumo/src/components/input/input.test.tsx b/packages/kumo/src/components/input/input.test.tsx new file mode 100644 index 0000000000..10dfc1ab1f --- /dev/null +++ b/packages/kumo/src/components/input/input.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { createRef } from "react"; +import { render, screen } from "@testing-library/react"; +import { + Input, + inputVariants, + KUMO_INPUT_VARIANTS, + KUMO_INPUT_DEFAULT_VARIANTS, +} from "./input"; + +describe("Input", () => { + // Rendering + it("renders a basic input element", () => { + render(); + expect(screen.getByRole("textbox")).toBeTruthy(); + }); + + it("forwards ref to the underlying input element", () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it("sets displayName to 'Input'", () => { + expect(Input.displayName).toBe("Input"); + }); + + it("applies custom className", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("custom-class"); + }); + + it("passes through native input attributes", () => { + render( + , + ); + const input = screen.getByRole("textbox"); + expect(input.getAttribute("placeholder")).toBe("Enter text"); + expect(input.getAttribute("type")).toBe("email"); + expect(input).toHaveProperty("disabled", true); + }); + + // Size variants + it("renders with default size 'base'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("h-9"); + }); + + it("renders with size 'xs'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("h-5"); + }); + + it("renders with size 'sm'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("h-6.5"); + }); + + it("renders with size 'lg'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("h-10"); + }); + + // Variant styles + it("renders with default variant 'default'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain( + "focus:ring-kumo-ring", + ); + }); + + it("renders with variant 'error'", () => { + render(); + expect(screen.getByRole("textbox").className).toContain("ring-kumo-danger"); + }); + + // Field wrapping + it("renders without Field wrapper when no label is provided", () => { + render(); + expect(screen.queryByRole("group")).toBeNull(); + }); + + it("renders with Field wrapper when label is provided", () => { + render(); + expect(screen.getByText("Email")).toBeTruthy(); + }); + + it("renders label text when label prop is set", () => { + render(); + expect(screen.getByText("Username")).toBeTruthy(); + }); + + it("renders description text when description prop is set", () => { + render(); + expect(screen.getByText("Must be 8+ characters")).toBeTruthy(); + }); + + it("renders error message when error is a string", () => { + render(); + expect(screen.getByText("Invalid email")).toBeTruthy(); + }); + + it("renders error message when error is an object with match", () => { + render( + , + ); + expect(screen.getByText("Required field")).toBeTruthy(); + }); + + // Accessibility + it("warns in dev when no accessible name is provided", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[Kumo Input]"), + ); + warnSpy.mockRestore(); + }); + + it("does not warn when label prop is set", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn when placeholder + aria-label are set", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn when aria-labelledby is set", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + // inputVariants function + it("returns base classes with default arguments", () => { + const classes = inputVariants(); + expect(classes).toContain("bg-kumo-control"); + expect(classes).toContain("text-kumo-default"); + }); + + it("applies size classes from KUMO_INPUT_VARIANTS", () => { + const classes = inputVariants({ size: "lg" }); + expect(classes).toContain("h-10"); + expect(classes).toContain("px-4"); + }); + + it("applies variant classes from KUMO_INPUT_VARIANTS", () => { + const classes = inputVariants({ variant: "error" }); + expect(classes).toContain("ring-kumo-danger"); + }); + + it("applies parentFocusIndicator class when true", () => { + const classes = inputVariants({ parentFocusIndicator: true }); + expect(classes).toContain("focus-within"); + }); + + it("applies focusIndicator class when true", () => { + const classes = inputVariants({ focusIndicator: true }); + expect(classes).toContain("focus:ring-kumo-ring"); + }); + + // Variants export + it("exports KUMO_INPUT_VARIANTS with size and variant axes", () => { + expect(KUMO_INPUT_VARIANTS.size.xs).toBeDefined(); + expect(KUMO_INPUT_VARIANTS.size.sm).toBeDefined(); + expect(KUMO_INPUT_VARIANTS.size.base).toBeDefined(); + expect(KUMO_INPUT_VARIANTS.size.lg).toBeDefined(); + expect(KUMO_INPUT_VARIANTS.variant.default).toBeDefined(); + expect(KUMO_INPUT_VARIANTS.variant.error).toBeDefined(); + }); + + it("exports KUMO_INPUT_DEFAULT_VARIANTS with correct defaults", () => { + expect(KUMO_INPUT_DEFAULT_VARIANTS.size).toBe("base"); + expect(KUMO_INPUT_DEFAULT_VARIANTS.variant).toBe("default"); + }); +}); diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 316907d82b..95ee89741c 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -87,6 +87,10 @@ export { Textarea, type InputAreaProps, InputGroup, + type InputGroupRootProps, + type InputGroupAddonProps, + type InputGroupSuffixProps, + type InputGroupInputProps, } from "./components/input"; export { LayerCard } from "./components/layer-card"; export { diff --git a/packages/kumo/vite.config.ts b/packages/kumo/vite.config.ts index c448bc8e48..8b12cd8c00 100644 --- a/packages/kumo/vite.config.ts +++ b/packages/kumo/vite.config.ts @@ -107,6 +107,10 @@ export default defineConfig(({ mode }) => { __dirname, "src/components/input/index.ts", ), + "components/input-group": resolve( + __dirname, + "src/components/input-group/index.ts", + ), "components/layer-card": resolve( __dirname, "src/components/layer-card/index.ts", From cb314f387c2b92ea11fb5d99475d983ad3f976ac Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Wed, 18 Mar 2026 10:40:09 -0400 Subject: [PATCH 02/28] feat(InputGroup): new compound component with Addon, Suffix, and Button support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New InputGroup compound component for building inputs with icons, addons, inline suffixes, and action buttons. Features: - Field Integration — Accepts label, description, error, required, and labelTooltip props - Addons — Place icons or text before/after the input using align="start" or align="end" - Compact Button — Small button inside an Addon for secondary actions - Action Button — Full-height flush button as a direct child for primary actions - Inline Suffix — Text that flows seamlessly next to the typed value (uses CSS field-sizing) - Size Variants — xs, sm, base, lg sizes cascade to all children via context - Error State — Error flows through context; InputGroup.Input auto-sets aria-invalid - Disabled State — disabled prop disables all interactive children Sub-components: - InputGroup — Root container; provides context and accepts Field props - InputGroup.Input — Styled input; inherits size, disabled, error from context - InputGroup.Addon — Container for icons, text, or compact buttons - InputGroup.Button — Full-height button (direct child) or compact button (inside Addon) - InputGroup.Suffix — Inline text suffix with CSS-based automatic width sizing Includes comprehensive documentation page with demos and unit tests. --- input-group-review.md | 282 +++++++++++++++++++++++++++++++ packages/kumo-docs-astro/dist.md | 0 2 files changed, 282 insertions(+) create mode 100644 input-group-review.md create mode 100644 packages/kumo-docs-astro/dist.md diff --git a/input-group-review.md b/input-group-review.md new file mode 100644 index 0000000000..84ce0af44a --- /dev/null +++ b/input-group-review.md @@ -0,0 +1,282 @@ +# Code Review for PR #249: InputGroup Compound Component + +## Summary + +Great work on this compound component! The API design is clean and the Field integration is well thought out. I found one significant bug causing layout flicker, plus a few minor documentation issues. + +--- + +## Bug: Layout flicker on initial render with Suffix + +**Severity: Major** + +**Files:** + +- `packages/kumo/src/components/input-group/input-group-suffix.tsx:25` +- `packages/kumo/src/components/input-group/input-group-addon.tsx:42` +- `packages/kumo/src/components/input-group/input-group.tsx:170-195` +- `packages/kumo/src/components/input-group/input-group-input.tsx:23-44` + +**Problem:** When an InputGroup contains a Suffix, there's a visible flash on page load where the input expands to full width, then snaps to fit its content. This happens because: + +1. Suffix and Addon use `useEffect` to register themselves with context +2. The parent's measurement logic runs in `useLayoutEffect` but depends on `hasSuffix` state +3. Since `useEffect` runs after paint, the first frame renders without suffix detection + +**Suggested fix:** The component already uses `:has()` selectors with `data-slot` attributes for addon padding adjustments. The same pattern can replace the JS measurement entirely using CSS `field-sizing: content`: + +```tsx +// In input-group.tsx container className: +"has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]", +"has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full", +"has-[[data-slot=input-group-suffix]]:[&_input]:grow-0", +"has-[[data-slot=input-group-suffix]]:[&_input]:pr-0", +``` + +This eliminates: + +- The ghost element measurement hack +- `registerInline`/`unregisterInline` context callbacks +- `hasSuffix` and `inputValue` state tracking +- The `useLayoutEffect` measurement logic in Root +- The `useEffect` registration in Suffix and Addon + +Browser support for `field-sizing` is ~80% (Chrome 123+, Safari 26.2+). Firefox doesn't support it yet, but the fallback (input stays wider) is acceptable progressive enhancement - no flicker, just less precise sizing. + +--- + +## Documentation Issues + +**Severity: Minor** + +1. **`packages/kumo-docs-astro/src/pages/components/input-group.astro:24`** - Incorrect `sourceFile` prop: + + ```diff + - sourceFile="components/input" + + sourceFile="components/input-group" + ``` + +2. **`packages/kumo-docs-astro/src/pages/components/input-group.astro:48-49`** - Granular import path is wrong: + ```diff + - import { InputGroup } from "@cloudflare/kumo/components/input"; + + import { InputGroup } from "@cloudflare/kumo/components/input-group"; + ``` + +--- + +## Overall + +**Minor Comments** - The core implementation is solid. The flicker bug should be addressed before merge since it's user-visible. The CSS-only approach would also simplify the codebase significantly by removing ~50 lines of measurement logic. + +--- + +## Suggested Diff + +Here's a working implementation of the CSS-only approach: + +```diff +diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx +index 27ee9c62..bd93cb1f 100644 +--- a/packages/kumo/src/components/input-group/input-group-addon.tsx ++++ b/packages/kumo/src/components/input-group/input-group-addon.tsx +@@ -2,7 +2,6 @@ import { + Children, + cloneElement, + useContext, +- useEffect, + useMemo, + isValidElement, + type ReactElement, +@@ -34,15 +33,6 @@ export function Addon({ + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; +- const hasInline = context?.hasSuffix; +- +- const registerAddon = context?.registerAddon; +- const unregisterAddon = context?.unregisterAddon; +- +- useEffect(() => { +- registerAddon?.(align); +- return () => unregisterAddon?.(align); +- }, [align, registerAddon, unregisterAddon]); + + const addonContext = useMemo( + () => (context ? { ...context, insideAddon: true } : null), +@@ -61,9 +51,7 @@ export function Addon({ + }); + }); + +- // In inline mode (Suffix present), addons overlay as absolute elements +- // since the grid layout is reserved for the input + suffix measurement. +- // In standard flex mode, addons are flow-based flex items. ++ // Always use flex-based positioning. CSS order controls visual placement. + return ( + +
+diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx +index 3710f02e..34e7fb55 100644 +--- a/packages/kumo/src/components/input-group/input-group-input.tsx ++++ b/packages/kumo/src/components/input-group/input-group-input.tsx +@@ -1,5 +1,4 @@ +-import { useCallback, useContext, useLayoutEffect } from "react"; +-import type { ChangeEvent } from "react"; ++import { useContext } from "react"; + import { cn } from "../../utils/cn"; + import { Input as InputExternal, type InputProps } from "../input/input"; + import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; +@@ -20,29 +19,6 @@ export function Input(props: InputGroupInputProps) { + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + +- // Track input value in context so Suffix can measure it +- const handleChange = useCallback( +- ( +- e: ChangeEvent & { +- preventBaseUIHandler: () => void; +- }, +- ) => { +- context?.setInputValue(e.target.value); +- props.onChange?.(e); +- }, +- [context?.setInputValue, props.onChange], +- ); +- +- // Sync controlled/default value into context before paint +- // so the Suffix ghost measurement is accurate on first render. +- useLayoutEffect(() => { +- if (props.value !== undefined) { +- context?.setInputValue(String(props.value)); +- } else if (props.defaultValue !== undefined) { +- context?.setInputValue(String(props.defaultValue)); +- } +- }, [props.value, props.defaultValue, context?.setInputValue]); +- + // Auto-set aria-invalid when error is present in context + const hasError = Boolean(context?.error); + +@@ -53,19 +29,13 @@ export function Input(props: InputGroupInputProps) { + disabled={context?.disabled || props.disabled} + aria-invalid={hasError || props["aria-invalid"]} + {...props} +- onChange={handleChange} + className={cn( +- "flex h-full min-w-0 items-center rounded-none border-0 font-sans", ++ "flex h-full min-w-0 grow items-center rounded-none border-0 bg-transparent font-sans", + // Always use full outer padding — the container's has-[] rules reduce + // pl/pr to inputSeam on sides that touch an addon. + tokens.inputOuter, +- context?.hasSuffix +- ? cn( +- "bg-transparent! overflow-hidden transition-none", +- // In inline mode the suffix owns its side — drop that padding. +- "pr-0!", +- ) +- : "grow bg-transparent", ++ // Truncate with ellipsis when text overflows ++ "text-ellipsis", + "ring-0! shadow-none focus:ring-0!", + props.className, + )} +diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx +index 5c74ea30..879dfea2 100644 +--- a/packages/kumo/src/components/input-group/input-group-suffix.tsx ++++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx +@@ -1,4 +1,4 @@ +-import { useContext, useEffect, type ReactNode } from "react"; ++import { useContext, type ReactNode } from "react"; + import { cn } from "../../utils/cn"; + import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; + +@@ -11,7 +11,7 @@ export interface InputGroupSuffixProps { + + /** + * Inline suffix that flows seamlessly next to the typed input value. +- * Input width adjusts automatically as the user types. ++ * Input width adjusts automatically via CSS field-sizing: content. + */ + export function Suffix({ className, children }: InputGroupSuffixProps) { + const context = useContext(InputGroupContext); +@@ -19,21 +19,13 @@ export function Suffix({ className, children }: InputGroupSuffixProps) { + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + +- const registerInline = context?.registerInline; +- const unregisterInline = context?.unregisterInline; +- +- useEffect(() => { +- registerInline?.(); +- return () => unregisterInline?.(); +- }, [registerInline, unregisterInline]); +- + return ( +
+diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx +index 32e74282..42ad5c83 100644 +--- a/packages/kumo/src/components/input-group/input-group.tsx ++++ b/packages/kumo/src/components/input-group/input-group.tsx +@@ -196,16 +196,6 @@ const Root = forwardRef>( + + const container = ( + +- {/* Ghost element for suffix width measurement */} +- {hasSuffix && ( +-
+-
+- )} +
>( + ], + inputVariants({ size }), + "px-0", +- hasSuffix +- ? "grid grid-cols-[auto_1fr] items-center gap-0" +- : "flex items-center gap-0", ++ "flex items-center gap-0", ++ // CSS-only input sizing when suffix present: shrink to content, strip right padding ++ "has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]", ++ "has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full", ++ "has-[[data-slot=input-group-suffix]]:[&_input]:grow-0", ++ "has-[[data-slot=input-group-suffix]]:[&_input]:pr-0", + INPUT_GROUP_HAS_CLASSES[size], + className, + )} +``` + +Note: After applying this diff, the context can also be cleaned up to remove `hasSuffix`, `inputValue`, `setInputValue`, `registerInline`, `unregisterInline`, `registerAddon`, `unregisterAddon`, `hasStartAddon`, `hasEndAddon`, and the `ghostRef` in `input-group.tsx`. diff --git a/packages/kumo-docs-astro/dist.md b/packages/kumo-docs-astro/dist.md new file mode 100644 index 0000000000..e69de29bb2 From 15ecbc36174e2173681cab69c5caa3327281fff6 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Wed, 18 Mar 2026 17:42:51 +0000 Subject: [PATCH 03/28] fix(InputGroup): show focus ring on container, not inner input - Suppress outline on inner input with outline-none! - Add has-[:focus-visible]:outline-auto to container in grouped mode - When inner input receives keyboard focus, the native focus ring appears on the entire InputGroup container - Uses browser's native -webkit-focus-ring-color for consistent appearance --- .../kumo/src/components/input-group/input-group.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx index 32e742829c..03dda0a2d8 100644 --- a/packages/kumo/src/components/input-group/input-group.tsx +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -212,17 +212,22 @@ const Root = forwardRef>( data-disabled={disabled ? "" : undefined} className={cn( "relative w-full cursor-text", - "shadow-xs ring ring-kumo-ring", - "has-[input[aria-invalid=true]]:ring-kumo-danger", + // inputVariants provides base ring-kumo-line; must come before state overrides + inputVariants({ size }), + "shadow-xs", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", focusMode === "individual" ? "isolate overflow-visible" : [ "overflow-hidden", "has-[[data-slot=input-group-button]]:overflow-visible", - "has-[:focus-visible]:ring-kumo-ring", + // Focus state must come AFTER inputVariants to override ring-kumo-line + "focus-within:ring-kumo-ring", + // Native focus outline on container when any child has focus-visible + "has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[-webkit-focus-ring-color]", ], - inputVariants({ size }), + // Error state must also come after inputVariants + "has-[input[aria-invalid=true]]:ring-kumo-danger", "px-0", hasSuffix ? "grid grid-cols-[auto_1fr] items-center gap-0" From d3e141fe707dce9cd5098dbcef01cbf94e4f9bbe Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Wed, 18 Mar 2026 20:19:57 +0000 Subject: [PATCH 04/28] fix(InputGroup): address review feedback - Fix addon padding with explicit pl-*/pr-* classes (Tailwind JIT compatible) - Add type="button" to flush Button to prevent form submission - Add forwardRef to InputGroup.Input for ref forwarding - Add tests for error state (aria-invalid) and Field integration - Update component registry with ring-kumo-line color token --- .../input-group/input-group-button.tsx | 1 + .../input-group/input-group.test.tsx | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/kumo/src/components/input-group/input-group-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx index 6b4e15ef60..43a8046f54 100644 --- a/packages/kumo/src/components/input-group/input-group-button.tsx +++ b/packages/kumo/src/components/input-group/input-group-button.tsx @@ -37,6 +37,7 @@ export function Button({ return ( { }); }); + describe("error handling", () => { + it("sets aria-invalid on input when error is present", () => { + render( + + + , + ); + + const input = screen.getByRole("textbox"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }); + + it("does not set aria-invalid when no error is present", () => { + render( + + + , + ); + + const input = screen.getByRole("textbox"); + expect(input.getAttribute("aria-invalid")).toBeFalsy(); + }); + }); + describe("size variants", () => { it("applies size to input", () => { const { rerender } = render( @@ -240,4 +264,41 @@ describe("InputGroup", () => { expect(screen.getByRole("group")).toBeTruthy(); }); }); + + describe("Field integration", () => { + it("renders label when label prop is provided", () => { + render( + + + , + ); + + expect(screen.getByText("Email address")).toBeTruthy(); + // The input should be associated with the label + expect(screen.getByLabelText("Email address")).toBeTruthy(); + }); + + it("renders description when description prop is provided", () => { + render( + + + , + ); + + expect(screen.getByText("We'll never share your email")).toBeTruthy(); + }); + + it("renders error message when error prop is provided with label", () => { + render( + + + , + ); + + expect(screen.getByText("Invalid email")).toBeTruthy(); + }); + }); }); From d553d4bc943dc8d69f5ff739e3bc2c1f36fcc745 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Wed, 18 Mar 2026 20:23:27 +0000 Subject: [PATCH 05/28] fix(InputGroup): hide ring border when focus outline is visible --- packages/kumo/src/components/input-group/input-group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx index 03dda0a2d8..7535b83e40 100644 --- a/packages/kumo/src/components/input-group/input-group.tsx +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -224,7 +224,7 @@ const Root = forwardRef>( // Focus state must come AFTER inputVariants to override ring-kumo-line "focus-within:ring-kumo-ring", // Native focus outline on container when any child has focus-visible - "has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[-webkit-focus-ring-color]", + "has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[-webkit-focus-ring-color] has-[:focus-visible]:ring-transparent", ], // Error state must also come after inputVariants "has-[input[aria-invalid=true]]:ring-kumo-danger", From 1f1a98d9aedaa2f9bb3b4b576d8ff0cb51b90201 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Thu, 19 Mar 2026 14:19:23 +0000 Subject: [PATCH 06/28] fix(input-group): simplify flush button, improve consistency - Revert flush button to render inside container (fixes focus ring issues) - Make addon padding symmetric (start and end now match) - Add forwardRef to InputGroup.Button for consistency - Pass disabled state from context to Button - Add tests for button variant and disabled state - Fix stale JSDoc comment - Use ring-0 instead of ring ring-transparent for clarity --- .../src/components/demos/InputGroupDemo.tsx | 9 +++-- .../src/pages/components/input-group.astro | 5 +-- .../input-group/input-group-button.tsx | 34 ++++++++----------- .../input-group/input-group.test.tsx | 28 +++++++++++++++ packages/kumo/src/styles/kumo-binding.css | 6 ++++ 5 files changed, 57 insertions(+), 25 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx index 94168f148b..1210d3b431 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -140,12 +140,15 @@ export function InputGroupButtonsDemo() { + + + - Submit + Search
); diff --git a/packages/kumo-docs-astro/src/pages/components/input-group.astro b/packages/kumo-docs-astro/src/pages/components/input-group.astro index e5f62d1db5..ed97c07dae 100644 --- a/packages/kumo-docs-astro/src/pages/components/input-group.astro +++ b/packages/kumo-docs-astro/src/pages/components/input-group.astro @@ -182,8 +182,9 @@ export default function Example() { {/* Button as direct child (full-height, flush) */} - - Submit + + + Search `} > diff --git a/packages/kumo/src/components/input-group/input-group-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx index 43a8046f54..9061a1f7bf 100644 --- a/packages/kumo/src/components/input-group/input-group-button.tsx +++ b/packages/kumo/src/components/input-group/input-group-button.tsx @@ -1,4 +1,4 @@ -import { useContext, type PropsWithChildren } from "react"; +import { forwardRef, useContext, type PropsWithChildren } from "react"; import { cn } from "../../utils/cn"; import { type ButtonProps, Button as ButtonExternal } from "../button/button"; import { InputGroupContext } from "./context"; @@ -8,22 +8,22 @@ export type InputGroupButtonProps = ButtonProps; /** * Button that renders differently based on placement: * - Inside Addon: compact button for secondary actions (toggle, copy) - * - Direct child: full-height flush button for primary actions (search, submit) + * - Direct child: full-height flush button rendered inside the container */ -export function Button({ - children, - className, - size, - ...props -}: PropsWithChildren) { +export const Button = forwardRef< + HTMLButtonElement, + PropsWithChildren +>(({ children, className, size, disabled, ...props }, ref) => { const context = useContext(InputGroupContext); - const isInsideAddon = context?.insideAddon ?? false; + const isDisabled = disabled ?? context?.disabled; if (isInsideAddon) { return ( {children} ); -} +}); Button.displayName = "InputGroup.Button"; diff --git a/packages/kumo/src/components/input-group/input-group.test.tsx b/packages/kumo/src/components/input-group/input-group.test.tsx index 81a594cb9c..06418dc5ee 100644 --- a/packages/kumo/src/components/input-group/input-group.test.tsx +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -185,6 +185,20 @@ describe("InputGroup", () => { await user.type(input, "hello"); expect(handleChange).not.toHaveBeenCalled(); }); + + it("disables button when InputGroup is disabled", () => { + render( + + + Submit + , + ); + + const button = screen.getByRole("button", { + name: "Submit", + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); }); describe("error handling", () => { @@ -265,6 +279,20 @@ describe("InputGroup", () => { }); }); + describe("Button", () => { + it("renders with specified variant", () => { + render( + + + Search + , + ); + + const button = screen.getByRole("button", { name: "Search" }); + expect(button.className).toContain("bg-kumo-brand"); + }); + }); + describe("Field integration", () => { it("renders label when label prop is provided", () => { render( diff --git a/packages/kumo/src/styles/kumo-binding.css b/packages/kumo/src/styles/kumo-binding.css index 69cbb036aa..5ca606b8ac 100644 --- a/packages/kumo/src/styles/kumo-binding.css +++ b/packages/kumo/src/styles/kumo-binding.css @@ -220,3 +220,9 @@ .kumo-input-placeholder::placeholder { color: var(--text-color-kumo-placeholder); } + +/* InputGroup focus ring — native browser outline on keyboard focus */ +[data-slot="input-group"]:has(:focus-visible) { + outline: auto; + outline: solid 2px -webkit-focus-ring-color; +} From e12f424cdbd15df37ca34f80bda7a4c5365ea07f Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Thu, 19 Mar 2026 14:55:05 +0000 Subject: [PATCH 07/28] fix(InputGroup): restore focus ring styling after cherry-pick - Add data-slot="input-group" for CSS targeting - Remove overflow-visible rule for buttons (breaks clipping) - Remove inline ring-transparent (was hiding focus ring) - Add outline-none to inner input (prevent double outline) - Let kumo-binding.css handle native outline --- .../kumo/src/components/input-group/input-group-input.tsx | 2 +- packages/kumo/src/components/input-group/input-group.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx index 3710f02e22..58023e9989 100644 --- a/packages/kumo/src/components/input-group/input-group-input.tsx +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -66,7 +66,7 @@ export function Input(props: InputGroupInputProps) { "pr-0!", ) : "grow bg-transparent", - "ring-0! shadow-none focus:ring-0!", + "ring-0! shadow-none outline-none focus:ring-0! focus:outline-none", props.className, )} /> diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx index 7535b83e40..b7de3cc2a8 100644 --- a/packages/kumo/src/components/input-group/input-group.tsx +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -209,6 +209,7 @@ const Root = forwardRef>(
>( ? "isolate overflow-visible" : [ "overflow-hidden", - "has-[[data-slot=input-group-button]]:overflow-visible", // Focus state must come AFTER inputVariants to override ring-kumo-line "focus-within:ring-kumo-ring", - // Native focus outline on container when any child has focus-visible - "has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[-webkit-focus-ring-color] has-[:focus-visible]:ring-transparent", + // The CSS in kumo-binding.css handles the native outline ], // Error state must also come after inputVariants "has-[input[aria-invalid=true]]:ring-kumo-danger", From 509037cd3be493d1b87be5f9cc2ad48acb8875d6 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Thu, 19 Mar 2026 15:01:33 +0000 Subject: [PATCH 08/28] docs(InputGroup): convert to MDX format Match the docs format change from main (commit adfc9ab) --- .../src/pages/components/input-group.astro | 500 ------------------ .../src/pages/components/input-group.mdx | 478 +++++++++++++++++ 2 files changed, 478 insertions(+), 500 deletions(-) delete mode 100644 packages/kumo-docs-astro/src/pages/components/input-group.astro create mode 100644 packages/kumo-docs-astro/src/pages/components/input-group.mdx diff --git a/packages/kumo-docs-astro/src/pages/components/input-group.astro b/packages/kumo-docs-astro/src/pages/components/input-group.astro deleted file mode 100644 index ed97c07dae..0000000000 --- a/packages/kumo-docs-astro/src/pages/components/input-group.astro +++ /dev/null @@ -1,500 +0,0 @@ ---- -import DocLayout from "../../layouts/DocLayout.astro"; -import Heading from "../../components/docs/Heading.astro"; -import ComponentSection from "../../components/docs/ComponentSection.astro"; -import ComponentExample from "../../components/docs/ComponentExample.astro"; -import CodeBlock from "../../components/docs/CodeBlock.astro"; -import PropsTable from "../../components/docs/PropsTable.astro"; -import { - InputGroupHeroDemo, - InputGroupIconsDemo, - InputGroupTextDemo, - InputGroupButtonsDemo, - InputGroupKbdDemo, - InputGroupLoadingDemo, - InputGroupSuffixDemo, - InputGroupSizesDemo, - InputGroupStatesDemo, -} from "../../components/demos/InputGroupDemo"; ---- - - - - - - - .workers.dev -`} - > - - - - - - - Installation - Barrel - - Granular - - - - - - Usage - - - - - - - ); -}`} - lang="tsx" - /> - - - - - Examples - -
-
- Icon -

- Use Addon to place icons at the start or end of the input. -

- - {/* Start icon */} - - - - - - - - {/* End icon */} - - - - - - - - {/* Both sides */} - - - - - - - - - -`} - > - - -
- -
- Text -

- Use Addon to place text prefixes or suffixes alongside the input. -

- - {/* Start only */} - - @ - - - - {/* End only */} - - - @example.com - - - {/* Both sides */} - - /api/ - - .json - -`} - > - - -
- -
- Button -

- Place InputGroup.Button inside an Addon for compact inset buttons, or directly as a child for a full-height flush button. -

- - {/* Icon button inside Addon (compact, inset) */} - - - - {}} - > - {show ? : } - - - - - {/* Text button inside Addon (compact, inset) */} - - - - Apply - - - - {/* Button as direct child (full-height, flush) */} - - - - Search - -`} - > - - -
- -
- Kbd -

- Place a keyboard shortcut hint inside an end Addon. -

- - - - - - - - ⌘K - - -`} - > - - -
- -
- Loading -

- Place a Loader inside an Addon at the start or end. Combine with a text span for a status label. -

- - {/* Spinner at end */} - - - - - - - - {/* Spinner at start */} - - - - - - - - {/* Text + spinner at end */} - - - - Saving... - - - -`} - > - - -
- -
- Inline Suffix -

- Suffix renders text that flows seamlessly next to the typed value — useful for domain inputs like .workers.dev. Truncates with ellipsis when space is limited. -

- - - .workers.dev - - - -`} - > - - -
- -
- Sizes -

- Four sizes: xs, sm, base (default), and lg. The size applies to the entire group. Use the label prop on InputGroup for built-in Field support. -

- - {/* Extra small */} - - - - - - - - {/* Small */} - - - - - - - - {/* Base (default) */} - - - - - - - - {/* Large */} - - - - - - -`} - > - - -
- -
- States -

- Various input states including error, disabled, and with description. Pass label, error, and description props directly to InputGroup. -

- - {/* Error state */} - - - @example.com - - - {/* Disabled */} - - - - - - Search - - - {/* With description and tooltip */} - - - - {}} - > - {show ? : } - - - -`} - > - - -
-
-
- - - - API Reference - -
-
-

InputGroup

-

- The root container that provides context to all child components. Accepts Field props - (label, - description, - error) and wraps content in a Field when label is provided. -

- -
- -
-

InputGroup.Input

-

- The text input element. Inherits size, - disabled, and - error from InputGroup context. - Accepts all standard input attributes except Field-related props which are handled by the parent. -

- -
- -
-

InputGroup.Addon

-

- Container for icons, text, or compact buttons positioned at the start or end of the input. -

- -
- -
-

InputGroup.Suffix

-

- Inline text that flows seamlessly next to the typed value (e.g., .workers.dev). - The input width adjusts automatically as the user types. -

- -
-
- -

Validation Error Types

-

- When using error as - an object, the match - property corresponds to HTML5 ValidityState values: -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MatchDescription
valueMissingRequired field is empty
typeMismatchValue doesn't match type (e.g., invalid email)
patternMismatchValue doesn't match pattern attribute
tooShortValue shorter than minLength
tooLongValue longer than maxLength
rangeUnderflowValue less than min
rangeOverflowValue greater than max
trueAlways show error (for server-side validation)
-
-
- - - - Accessibility -
-
-

Label Requirement

-

InputGroup requires an accessible name via one of:

-
    -
  • - label prop on InputGroup (renders a visible label with built-in Field support) -
  • -
  • - aria-label on InputGroup.Input for inputs without a visible label -
  • -
  • - aria-labelledby on InputGroup.Input for custom label association -
  • -
-

- Missing accessible names trigger console warnings in development. -

-
-
-

Group Role

-

- InputGroup automatically renders with role="group", which semantically associates the input with its addons for assistive technologies. -

-
-
-
-
diff --git a/packages/kumo-docs-astro/src/pages/components/input-group.mdx b/packages/kumo-docs-astro/src/pages/components/input-group.mdx new file mode 100644 index 0000000000..96e8514afd --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/input-group.mdx @@ -0,0 +1,478 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "InputGroup" +description: "Compose inputs with addons, icons, buttons, and text for rich form fields." +sourceFile: "components/input" +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import CodeBlock from "~/components/docs/CodeBlock.astro"; +import Heading from "~/components/docs/Heading.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + InputGroupHeroDemo, + InputGroupIconsDemo, + InputGroupTextDemo, + InputGroupButtonsDemo, + InputGroupKbdDemo, + InputGroupLoadingDemo, + InputGroupSuffixDemo, + InputGroupSizesDemo, + InputGroupStatesDemo, +} from "~/components/demos/InputGroupDemo"; + + + + + .workers.dev +`} + > + + + + + + Installation + Barrel + + Granular + + + + + Usage + + + + + + + ); +}`} + lang="tsx" + /> + + + + Examples + + Icon +

+ Use Addon to place icons at the start or end of the input. +

+ + {/* Start icon */} + + + + + + + +{/* End icon */} + + + + + + + + +{/* Both sides */} + + + + + + + + + + +`} + > + + + + Text +

+ Use Addon to place text prefixes or suffixes alongside the input. +

+ + {/* Start only */} + + @ + + + +{/* End only */} + + + + @example.com + + +{/* Both sides */} + + + /api/ + + .json + +`} + > + + + + Button +

+ Place InputGroup.Button inside an Addon for compact inset buttons, or directly as a child for a full-height flush button. +

+ + {/* Icon button inside Addon (compact, inset) */} + + + + {}} + > + {show ? : } + + + + +{/* Text button inside Addon (compact, inset) */} + + + + + Apply + + + +{/* Button as direct child (full-height, flush) */} + + + + + Search + +`} + > + + + +Kbd +

Place a keyboard shortcut hint inside an end Addon.

+ + + + + + + + ⌘K + + +`} +> + + + + Loading +

+ Place a Loader inside an Addon at the start or end. Combine with a text span for a status label. +

+ + {/* Spinner at end */} + + + + + + + +{/* Spinner at start */} + + + + + + + + +{/* Text + spinner at end */} + + + + + Saving... + + + +`} + > + + + +Inline Suffix +

+ Suffix renders text that flows seamlessly next to the typed value — useful for + domain inputs like `.workers.dev`. Truncates with ellipsis when space is + limited. +

+ + + .workers.dev + + + +`} +> + + + + Sizes +

+ Four sizes: `xs`, `sm`, `base` (default), and `lg`. The size applies to the entire group. Use the `label` prop on `InputGroup` for built-in Field support. +

+ + {/* Extra small */} + + + + + + + +{/* Small */} + + + + + + + + +{/* Base (default) */} + + + + + + + + +{/* Large */} + + + + + + + +`} + > + + + + States +

+ Various input states including error, disabled, and with description. Pass `label`, `error`, and `description` props directly to `InputGroup`. +

+ + {/* Error state */} + + + @example.com + + +{/* Disabled */} + + + + + + + Search + + +{/* With description and tooltip */} + + + + + {}} + > + {show ? : } + + + +`} + > + + +
+ + + API Reference + +`InputGroup` +

+ The root container that provides context to all child components. Accepts + Field props (`label`, `description`, `error`) and wraps content in a Field + when label is provided. +

+ + +`InputGroup.Input` +

+ The text input element. Inherits `size`, `disabled`, and `error` from + InputGroup context. Accepts all standard input attributes except Field-related + props which are handled by the parent. +

+ + +`InputGroup.Addon` +

+ Container for icons, text, or compact buttons positioned at the start or end + of the input. +

+ + +`InputGroup.Suffix` +

+ Inline text that flows seamlessly next to the typed value (e.g., + `.workers.dev`). The input width adjusts automatically as the user types. +

+ + + Validation Error Types +

+ When using `error` as an object, the `match` property corresponds to HTML5 ValidityState values: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MatchDescription
valueMissingRequired field is empty
typeMismatchValue doesn't match type (e.g., invalid email)
patternMismatchValue doesn't match pattern attribute
tooShortValue shorter than minLength
tooLongValue longer than maxLength
rangeUnderflowValue less than min
rangeOverflowValue greater than max
trueAlways show error (for server-side validation)
+
+
+ + + Accessibility +
+
+

Label Requirement

+

+ InputGroup requires an accessible name via one of: +

+
    +
  • + `label` prop on InputGroup (renders a visible label with built-in + Field support) +
  • +
  • + `aria-label` on InputGroup.Input for inputs without a visible label +
  • +
  • + `aria-labelledby` on InputGroup.Input for custom label association +
  • +
+

+ Missing accessible names trigger console warnings in development. +

+
+
+

Group Role

+

+ InputGroup automatically renders with `role="group"`, which semantically + associates the input with its addons for assistive technologies. +

+
+
+
From b3b308946b0df54aee2f3ac4b040e67a0936ff06 Mon Sep 17 00:00:00 2001 From: najlaskr <30641730+najlaskr@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:35:37 -0700 Subject: [PATCH 09/28] feat: InputGroup design updates + add tooltip variant for btn --- .changeset/feat-input-group-revamp.md | 5 +- .../src/components/demos/InputGroupDemo.tsx | 188 +++++++------ .../src/pages/components/input-group.mdx | 257 ++++++++---------- .../src/components/input-group/context.ts | 24 -- .../input-group/input-group-addon.tsx | 64 ++--- .../input-group/input-group-button.tsx | 75 +++-- .../input-group/input-group-input.tsx | 37 +-- .../input-group/input-group-suffix.tsx | 16 +- .../input-group/input-group.test.tsx | 46 +++- .../components/input-group/input-group.tsx | 99 +------ 10 files changed, 326 insertions(+), 485 deletions(-) diff --git a/.changeset/feat-input-group-revamp.md b/.changeset/feat-input-group-revamp.md index 13dee9c82d..e34a1002f0 100644 --- a/.changeset/feat-input-group-revamp.md +++ b/.changeset/feat-input-group-revamp.md @@ -10,9 +10,8 @@ New `InputGroup` compound component for building inputs with icons, addons, inli ## Features - Field Integration — InputGroup accepts `label`, `description`, `error`, `required`, and `labelTooltip` props directly; automatically wraps in Field when label is provided -- Addons — Place icons or text before/after the input using `align="start"` or `align="end"` -- Compact Button — Small button inside an Addon for secondary actions (copy, clear, toggle visibility) -- Action Button — Full-height flush button as a direct child for primary actions (submit, search) +- Addons — Place icons before the input using `align="start"` +- Compact Button — Icon button inside an Addon for secondary actions (i.e. clear, toggle visibility, tooltip) - Inline Suffix — Text that flows seamlessly next to the typed value (e.g., `.workers.dev`); input width adjusts automatically as user types - Size Variants — `xs`, `sm`, `base`, `lg` sizes cascade to all children via context - Error State — Error flows through context; `InputGroup.Input` auto-sets `aria-invalid="true"` when error is present diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx index 1210d3b431..e76f2ddd2a 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -3,13 +3,12 @@ import { InputGroup, Loader } from "@cloudflare/kumo"; import { MagnifyingGlassIcon, CheckCircleIcon, + XCircleIcon, + XIcon, EyeIcon, EyeSlashIcon, LinkIcon, - TagIcon, - AirplaneTakeoffIcon, - InfoIcon, - SpinnerIcon, + QuestionIcon, } from "@phosphor-icons/react"; /** Common props to disable browser features in demo inputs */ @@ -29,9 +28,9 @@ export function InputGroupHeroDemo() { export function InputGroupIconsDemo() { return (
- + - + - - - - - - - - - - - - - - - - -
); } @@ -103,9 +77,10 @@ export function InputGroupTextDemo() { ); } -/** Button variations: password toggle, inset button, and flush button */ +/** Button variations: password toggle and clear */ export function InputGroupButtonsDemo() { const [show, setShow] = useState(false); + const [searchValue, setSearchValue] = useState("search"); return (
@@ -116,44 +91,68 @@ export function InputGroupButtonsDemo() { aria-label="Password" {...demoInputProps} /> - + setShow(!show)} > - {show ? : } + {show ? : } setSearchValue(e.target.value)} {...demoInputProps} /> - - Apply - - - - - - - - - Search + {searchValue && ( + + setSearchValue("")} + > + + + + )}
); } +/** Search input with a tooltip button — mirrors the "Query language help" pattern */ +export function InputGroupTooltipButtonDemo() { + return ( + + + + + + + {}} + > + + + + + ); +} + /** Search input with keyboard shortcut hint */ export function InputGroupKbdDemo() { return ( @@ -167,67 +166,53 @@ export function InputGroupKbdDemo() { {...demoInputProps} /> - - ⌘K - + ⌘K ); } -/** Loading variations: spinner at end, spinner at start, text + spinner at end */ +/** Loading variation: spinner at end for domain validation */ export function InputGroupLoadingDemo() { return (
{/* Spinner at end */} - - - - - - {/* Spinner at start */} - - - - - - - - {/* Text + spinner at end */} - - - Saving... - +
); } -/** Inline suffix that follows typed text — shows spinner then success icon on edit */ +/** Inline suffix that follows typed text — shows spinner then success/error icon on edit */ export function InputGroupSuffixDemo() { - return ; + return ( +
+ + +
+ ); } /** Shared Workers suffix input with validation spinner and success icon */ -function WorkersSuffixInput({ defaultValue }: { defaultValue: string }) { +function WorkersSuffixInput({ + defaultValue, + resultState = "success", +}: { + defaultValue: string; + resultState?: "success" | "error"; +}) { const [value, setValue] = useState(defaultValue); - const [status, setStatus] = useState<"idle" | "loading" | "valid">("valid"); + const [status, setStatus] = useState< + "idle" | "loading" | "success" | "error" + >(resultState); const timerRef = useRef>(null); const handleChange = (e: React.ChangeEvent) => { @@ -238,14 +223,19 @@ function WorkersSuffixInput({ defaultValue }: { defaultValue: string }) { if (next.length > 0) { setStatus("loading"); - timerRef.current = setTimeout(() => setStatus("valid"), 1500); + timerRef.current = setTimeout(() => setStatus(resultState), 1500); } else { setStatus("idle"); } }; + const errorState = + status === "error" + ? { message: "This subdomain is unavailable", match: true as const } + : undefined; + return ( - + .workers.dev {status === "loading" && ( - + + + )} + {status === "success" && ( + + )} - {status === "valid" && ( + {status === "error" && ( - + )} @@ -274,28 +269,28 @@ export function InputGroupSizesDemo() {
- + - + - + - + @@ -326,7 +321,6 @@ export function InputGroupStatesDemo() { - Search Granular - - - - Usage - - - - - - - ); -}`} + code={`import { InputGroup } from "@cloudflare/kumo/components/input-group";`} lang="tsx" /> @@ -70,44 +51,20 @@ export default function Example() { Examples - Icon -

- Use Addon to place icons at the start or end of the input. -

- - {/* Start icon */} - - - - - - - -{/* End icon */} - - - - - +Icon +

+ Use Addon to place an icon at the start of the input as a visual identifier. +

+ + + -
- -{/* Both sides */} - - - - - - - - - - -`} - > - -
+ +`} +> + + Text

@@ -142,118 +99,141 @@ export default function Example() { Button

- Place InputGroup.Button inside an Addon for compact inset buttons, or directly as a child for a full-height flush button. + Place InputGroup.Button inside an Addon for actions that operate directly on the input value, such as reveal/hide or clear.

- {/* Icon button inside Addon (compact, inset) */} - - - + code={`{/* Reveal / hide password */} + + + + setShow(!show)} + > + {show ? : } + + + + +{/* Clearable search */} + + + setSearchValue(e.target.value)} + /> + {searchValue && ( + {}} + aria-label="Delete search" + onClick={() => setSearchValue("")} > - {show ? : } + - - -{/* Text button inside Addon (compact, inset) */} - - - - - Apply - - - -{/* Button as direct child (full-height, flush) */} - - - - - Search - -`} + )} +`} > +Button with Tooltip +

+ Pass a `tooltip` prop to `InputGroup.Button` to show a tooltip on hover. When + no explicit `aria-label` is provided, the button derives it from a string + tooltip value. +

+ + + + + + + setShowHelp(true)} + > + + + +`} +> + + + Kbd -

Place a keyboard shortcut hint inside an end Addon.

+

+ Place a keyboard shortcut hint inside an end Addon. Use a bare `kbd` element — + no border or background needed. +

+ code={` - - ⌘K - + ⌘K `} > - Loading -

- Place a Loader inside an Addon at the start or end. Combine with a text span for a status label. -

- - {/* Spinner at end */} - - - - - - - -{/* Spinner at start */} - - - - +Loading +

+ Place a Loader inside an end Addon as a status indicator while validating the + input value. +

+ + + + - -
- -{/* Text + spinner at end */} - - - - - Saving... - - - -`} - > - -
+`} +> + + Inline Suffix

Suffix renders text that flows seamlessly next to the typed value — useful for - domain inputs like `.workers.dev`. Truncates with ellipsis when space is - limited. + domain inputs like `.workers.dev`. Pair with a status icon Addon to show + validation state.

- + code={` + .workers.dev - - - + {status === "loading" && ( + + )} + {status === "success" && ( + + + + )} + {status === "error" && ( + + + + )} `} > @@ -329,7 +309,6 @@ export default function Example() { - Search {/* With description and tooltip */} diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts index 37b3fe8146..1c7daca57d 100644 --- a/packages/kumo/src/components/input-group/context.ts +++ b/packages/kumo/src/components/input-group/context.ts @@ -20,12 +20,8 @@ export interface InputGroupSizeTokens { inputOuter: string; /** Outer padding for icon/text Addon at the container edge. */ addonOuter: string; - /** pl- for ghost measurement span (matches inputOuter left). */ - ghostPad: string; /** pr- for suffix when no end addon. */ suffixPad: string; - /** pr- for suffix when end addon present (reserves icon space). */ - suffixReserve: string; fontSize: string; /** Icon size in px. */ iconSize: number; @@ -35,36 +31,28 @@ export const INPUT_GROUP_SIZE: Record = { xs: { inputOuter: "px-1.5", addonOuter: "px-1.5", - ghostPad: "pl-1.5", suffixPad: "pr-1.5", - suffixReserve: "pr-6", fontSize: "text-xs", iconSize: 10, }, sm: { inputOuter: "px-2", addonOuter: "px-1.5", - ghostPad: "pl-2", suffixPad: "pr-2", - suffixReserve: "pr-7", fontSize: "text-xs", iconSize: 13, }, base: { inputOuter: "px-3", addonOuter: "px-2", - ghostPad: "pl-3", suffixPad: "pr-3", - suffixReserve: "pr-9", fontSize: "text-base", iconSize: 18, }, lg: { inputOuter: "px-4", addonOuter: "px-2.5", - ghostPad: "pl-4", suffixPad: "pr-4", - suffixReserve: "pr-11", fontSize: "text-base", iconSize: 20, }, @@ -95,8 +83,6 @@ export const INPUT_GROUP_HAS_CLASSES: Record = { ].join(" "), }; -export const MIN_INPUT_WIDTH = 1; - // Derive directional padding from a symmetric "px-N" token. export function pl(px: string): string { return px.replace("px-", "pl-"); @@ -128,16 +114,6 @@ export interface InputGroupContextValue > { focusMode: "container" | "individual"; inputId: string; - hasStartAddon: boolean; - hasEndAddon: boolean; - hasSuffix: boolean; - insideAddon: boolean; - inputValue: string; - setInputValue: (value: string) => void; - registerAddon: (align: "start" | "end") => void; - unregisterAddon: (align: "start" | "end") => void; - registerInline: () => void; - unregisterInline: () => void; disabled: boolean; error?: FieldProps["error"]; } diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx index 27ee9c621a..596a174700 100644 --- a/packages/kumo/src/components/input-group/input-group-addon.tsx +++ b/packages/kumo/src/components/input-group/input-group-addon.tsx @@ -2,8 +2,6 @@ import { Children, cloneElement, useContext, - useEffect, - useMemo, isValidElement, type ReactElement, type ReactNode, @@ -34,20 +32,6 @@ export function Addon({ const size = context?.size ?? "base"; const tokens = INPUT_GROUP_SIZE[size]; - const hasInline = context?.hasSuffix; - - const registerAddon = context?.registerAddon; - const unregisterAddon = context?.unregisterAddon; - - useEffect(() => { - registerAddon?.(align); - return () => unregisterAddon?.(align); - }, [align, registerAddon, unregisterAddon]); - - const addonContext = useMemo( - () => (context ? { ...context, insideAddon: true } : null), - [context], - ); // Inject size into direct icon children that don't already have one set. // Skips buttons (which have their own size handling) and non-element nodes. @@ -61,37 +45,25 @@ export function Addon({ }); }); - // In inline mode (Suffix present), addons overlay as absolute elements - // since the grid layout is reserved for the input + suffix measurement. - // In standard flex mode, addons are flow-based flex items. + // Always use flex-based positioning. CSS order controls visual placement. return ( - -
- {sizedChildren} -
-
+
+ {sizedChildren} +
); } Addon.displayName = "InputGroup.Addon"; diff --git a/packages/kumo/src/components/input-group/input-group-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx index 9061a1f7bf..e3ee902e3d 100644 --- a/packages/kumo/src/components/input-group/input-group-button.tsx +++ b/packages/kumo/src/components/input-group/input-group-button.tsx @@ -1,51 +1,74 @@ -import { forwardRef, useContext, type PropsWithChildren } from "react"; +import { forwardRef, useContext, type PropsWithChildren, type ReactNode } from "react"; import { cn } from "../../utils/cn"; import { type ButtonProps, Button as ButtonExternal } from "../button/button"; +import { Tooltip, type KumoTooltipSide } from "../tooltip/tooltip"; import { InputGroupContext } from "./context"; -export type InputGroupButtonProps = ButtonProps; +export type InputGroupButtonProps = Omit & { + variant?: "ghost"; + shape?: "base"; + /** + * When provided, wraps the button in a `Tooltip` showing this content on hover. + * Automatically sets `aria-label` from a string value when no `aria-label` is set. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ + tooltip?: ReactNode; + /** + * Preferred side for the tooltip popup. + * @default "bottom" + */ + tooltipSide?: KumoTooltipSide; +}; /** - * Button that renders differently based on placement: - * - Inside Addon: compact button for secondary actions (toggle, copy) - * - Direct child: full-height flush button rendered inside the container + * Button for secondary actions rendered inside `InputGroup.Addon` + * (toggle, copy, help). + * + * Pass a `tooltip` prop to show a tooltip on hover. */ export const Button = forwardRef< HTMLButtonElement, PropsWithChildren ->(({ children, className, size, disabled, ...props }, ref) => { +>(({ children, className, size, disabled, tooltip, tooltipSide = "bottom", ...props }, ref) => { const context = useContext(InputGroupContext); - const isInsideAddon = context?.insideAddon ?? false; const isDisabled = disabled ?? context?.disabled; - if (isInsideAddon) { - return ( - - {children} - - ); - } + // Derive aria-label from tooltip string when the button has no explicit label. + // Icon-only buttons (shape="square"|"circle") require an aria-label for a11y. + const tooltipAriaLabel = + typeof tooltip === "string" && !props["aria-label"] ? tooltip : undefined; - // Flush button: rendered inside the container - return ( + const btn = ( {children} ); + + if (tooltip) { + return ( + + {btn} + + ); + } + + return btn; }); Button.displayName = "InputGroup.Button"; diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx index 58023e9989..56a16e3a7a 100644 --- a/packages/kumo/src/components/input-group/input-group-input.tsx +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -1,5 +1,4 @@ -import { useCallback, useContext, useLayoutEffect } from "react"; -import type { ChangeEvent } from "react"; +import { useContext } from "react"; import { cn } from "../../utils/cn"; import { Input as InputExternal, type InputProps } from "../input/input"; import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; @@ -20,29 +19,6 @@ export function Input(props: InputGroupInputProps) { const size = context?.size ?? "base"; const tokens = INPUT_GROUP_SIZE[size]; - // Track input value in context so Suffix can measure it - const handleChange = useCallback( - ( - e: ChangeEvent & { - preventBaseUIHandler: () => void; - }, - ) => { - context?.setInputValue(e.target.value); - props.onChange?.(e); - }, - [context?.setInputValue, props.onChange], - ); - - // Sync controlled/default value into context before paint - // so the Suffix ghost measurement is accurate on first render. - useLayoutEffect(() => { - if (props.value !== undefined) { - context?.setInputValue(String(props.value)); - } else if (props.defaultValue !== undefined) { - context?.setInputValue(String(props.defaultValue)); - } - }, [props.value, props.defaultValue, context?.setInputValue]); - // Auto-set aria-invalid when error is present in context const hasError = Boolean(context?.error); @@ -53,19 +29,12 @@ export function Input(props: InputGroupInputProps) { disabled={context?.disabled || props.disabled} aria-invalid={hasError || props["aria-invalid"]} {...props} - onChange={handleChange} className={cn( - "flex h-full min-w-0 items-center rounded-none border-0 font-sans", + "flex h-full min-w-0 grow items-center rounded-none border-0 bg-transparent font-sans", // Always use full outer padding — the container's has-[] rules reduce // pl/pr to inputSeam on sides that touch an addon. tokens.inputOuter, - context?.hasSuffix - ? cn( - "bg-transparent! overflow-hidden transition-none", - // In inline mode the suffix owns its side — drop that padding. - "pr-0!", - ) - : "grow bg-transparent", + "text-ellipsis", "ring-0! shadow-none outline-none focus:ring-0! focus:outline-none", props.className, )} diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx index 5c74ea305f..55b29a6d5a 100644 --- a/packages/kumo/src/components/input-group/input-group-suffix.tsx +++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, type ReactNode } from "react"; +import { useContext, type ReactNode } from "react"; import { cn } from "../../utils/cn"; import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; @@ -11,7 +11,7 @@ export interface InputGroupSuffixProps { /** * Inline suffix that flows seamlessly next to the typed input value. - * Input width adjusts automatically as the user types. + * Input width adjusts automatically via CSS `field-sizing: content`. */ export function Suffix({ className, children }: InputGroupSuffixProps) { const context = useContext(InputGroupContext); @@ -19,21 +19,13 @@ export function Suffix({ className, children }: InputGroupSuffixProps) { const size = context?.size ?? "base"; const tokens = INPUT_GROUP_SIZE[size]; - const registerInline = context?.registerInline; - const unregisterInline = context?.unregisterInline; - - useEffect(() => { - registerInline?.(); - return () => unregisterInline?.(); - }, [registerInline, unregisterInline]); - return (
diff --git a/packages/kumo/src/components/input-group/input-group.test.tsx b/packages/kumo/src/components/input-group/input-group.test.tsx index 06418dc5ee..e4c76b1a6d 100644 --- a/packages/kumo/src/components/input-group/input-group.test.tsx +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -20,7 +20,9 @@ describe("InputGroup", () => { render( - Go + + Go + , ); expect(screen.getByPlaceholderText("Search...")).toBeTruthy(); @@ -43,13 +45,13 @@ describe("InputGroup", () => { $ - USD - Pay + + Pay + , ); expect(screen.getByText("$")).toBeTruthy(); expect(screen.getByPlaceholderText("0.00")).toBeTruthy(); - expect(screen.getByText("USD")).toBeTruthy(); expect(screen.getByRole("button", { name: "Pay" })).toBeTruthy(); }); }); @@ -120,7 +122,9 @@ describe("InputGroup", () => { render( - Search + + Search + , ); @@ -190,7 +194,9 @@ describe("InputGroup", () => { render( - Submit + + Submit + , ); @@ -280,16 +286,34 @@ describe("InputGroup", () => { }); describe("Button", () => { - it("renders with specified variant", () => { + it("derives aria-label from tooltip string", () => { render( - - Search + + + ? + + , + ); + + expect( + screen.getByRole("button", { name: "Query language help" }), + ).toBeTruthy(); + }); + + it("prefers explicit aria-label over tooltip-derived label", () => { + render( + + + + + ? + + , ); - const button = screen.getByRole("button", { name: "Search" }); - expect(button.className).toContain("bg-kumo-brand"); + expect(screen.getByRole("button", { name: "Help" })).toBeTruthy(); }); }); diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx index b7de3cc2a8..63e392d708 100644 --- a/packages/kumo/src/components/input-group/input-group.tsx +++ b/packages/kumo/src/components/input-group/input-group.tsx @@ -3,10 +3,8 @@ import { useCallback, useEffect, useId, - useLayoutEffect, useMemo, useRef, - useState, type PropsWithChildren, } from "react"; import { cn } from "../../utils/cn"; @@ -14,9 +12,7 @@ import { inputVariants } from "../input/input"; import { Field } from "../field/field"; import { InputGroupContext, - INPUT_GROUP_SIZE, INPUT_GROUP_HAS_CLASSES, - MIN_INPUT_WIDTH, type InputGroupRootProps, } from "./context"; import { Input } from "./input-group-input"; @@ -87,27 +83,6 @@ const Root = forwardRef>( [forwardedRef], ); - const [addons, setAddons] = useState<{ - start: number; - end: number; - }>({ start: 0, end: 0 }); - const [hasSuffix, setHasSuffix] = useState(false); - const [inputValue, setInputValue] = useState(""); - - const registerAddon = useCallback((align: "start" | "end") => { - setAddons((prev) => ({ ...prev, [align]: prev[align] + 1 })); - }, []); - - const unregisterAddon = useCallback((align: "start" | "end") => { - setAddons((prev) => ({ - ...prev, - [align]: Math.max(0, prev[align] - 1), - })); - }, []); - - const registerInline = useCallback(() => setHasSuffix(true), []); - const unregisterInline = useCallback(() => setHasSuffix(false), []); - const contextValue = useMemo( () => ({ size, @@ -115,37 +90,10 @@ const Root = forwardRef>( inputId, disabled, error, - hasStartAddon: addons.start > 0, - hasEndAddon: addons.end > 0, - hasSuffix, - insideAddon: false, - inputValue, - setInputValue, - registerAddon, - unregisterAddon, - registerInline, - unregisterInline, }), - [ - size, - focusMode, - inputId, - disabled, - error, - addons.start, - addons.end, - hasSuffix, - inputValue, - registerAddon, - unregisterAddon, - registerInline, - unregisterInline, - ], + [size, focusMode, inputId, disabled, error], ); - const tokens = INPUT_GROUP_SIZE[size]; - const ghostRef = useRef(null); - // Focus input when clicking empty space in the container. // Attached via useEffect to keep the JSX clean for a11y linters. useEffect(() => { @@ -167,45 +115,8 @@ const Root = forwardRef>( return () => el.removeEventListener("mousedown", handleMouseDown); }, [disabled, inputId]); - // Measure ghost and update input width when suffix is present. - useLayoutEffect(() => { - if (!hasSuffix) return; - if (!inputId || !ghostRef.current) return; - - const input = document.getElementById(inputId) as HTMLInputElement | null; - if (!input) return; - - // Copy letter-spacing from the actual input so the ghost measurement - // matches exactly. - ghostRef.current.style.letterSpacing = - window.getComputedStyle(input).letterSpacing; - - const value = inputValue !== "" ? inputValue : input.value; - - if (value) { - ghostRef.current.textContent = value; - const measuredWidth = ghostRef.current.offsetWidth + 1; - const width = Math.max(measuredWidth, MIN_INPUT_WIDTH); - input.style.width = `${width}px`; - input.style.maxWidth = "100%"; - } else { - input.style.width = `${MIN_INPUT_WIDTH}px`; - input.style.maxWidth = ""; - } - }, [inputId, inputValue, hasSuffix]); - const container = ( - {/* Ghost element for suffix width measurement */} - {hasSuffix && ( -
-
- )}
>( // Error state must also come after inputVariants "has-[input[aria-invalid=true]]:ring-kumo-danger", "px-0", - hasSuffix - ? "grid grid-cols-[auto_1fr] items-center gap-0" - : "flex items-center gap-0", + "flex items-center gap-0", + "has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]", + "has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full", + "has-[[data-slot=input-group-suffix]]:[&_input]:grow-0", + "has-[[data-slot=input-group-suffix]]:[&_input]:pr-0", INPUT_GROUP_HAS_CLASSES[size], className, )} From 5056b2186ace0364183e8251ae9e627738759852 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Tue, 24 Mar 2026 15:59:38 +0000 Subject: [PATCH 10/28] feat(docs): add InputGroup to sidebar and HomeGrid --- .../kumo-docs-astro/src/components/SidebarNav.tsx | 1 + .../src/components/demos/HomeGrid.tsx | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 6dea28b986..6bcb6ccc73 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -57,6 +57,7 @@ const componentItems: NavItem[] = [ { label: "Grid", href: "/components/grid" }, { label: "Input", href: "/components/input" }, { label: "InputArea", href: "/components/input-area" }, + { label: "InputGroup", href: "/components/input-group" }, { label: "Label", href: "/components/label" }, { label: "Layer Card", href: "/components/layer-card" }, { label: "Link", href: "/components/link" }, diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index 92fdf15e4b..c194dcaa40 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -15,6 +15,7 @@ import { GridItem, Input, InputArea, + InputGroup, Label, LayerCard, Link, @@ -67,6 +68,7 @@ const componentRoutes: Record = { grid: "/components/grid", input: "/components/input", "input-area": "/components/input-area", + "input-group": "/components/input-group", label: "/components/label", "layer-card": "/components/layer-card", link: "/components/link", @@ -418,6 +420,16 @@ export function HomeGrid() { id: "input-area", Component: , }, + { + name: "InputGroup", + id: "input-group", + Component: ( + + @ + + + ), + }, { name: "Meter", id: "meter", From e39d42330a662596f2acf14a64a4d55e3c04a7d3 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Tue, 24 Mar 2026 16:02:06 +0000 Subject: [PATCH 11/28] feat(docs): use InputGroupHeroDemo (subdomain) in HomeGrid --- .../kumo-docs-astro/src/components/demos/HomeGrid.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index c194dcaa40..8f45b48407 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -15,7 +15,6 @@ import { GridItem, Input, InputArea, - InputGroup, Label, LayerCard, Link, @@ -39,6 +38,7 @@ import { useKumoToastManager, } from "@cloudflare/kumo"; import { ShikiProvider, CodeHighlighted } from "@cloudflare/kumo/code"; +import { InputGroupHeroDemo } from "~/components/demos/InputGroupDemo"; import { MagnifyingGlassIcon, PlusIcon, @@ -423,12 +423,7 @@ export function HomeGrid() { { name: "InputGroup", id: "input-group", - Component: ( - - @ - - - ), + Component: , }, { name: "Meter", From 4e04c4263cdfcdc3891b55bc75c78373f5e41638 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Tue, 24 Mar 2026 16:04:40 +0000 Subject: [PATCH 12/28] feat(docs): hide label in InputGroup HomeGrid example --- .../src/components/demos/InputGroupDemo.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx index e76f2ddd2a..0df8f9429d 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -21,7 +21,7 @@ const demoInputProps = { /** Workers URL with inline suffix — validates on edit with spinner then success */ export function InputGroupHeroDemo() { - return ; + return ; } /** Icon addons in various positions: start-only, end-only, and both */ @@ -205,9 +205,11 @@ export function InputGroupSuffixDemo() { function WorkersSuffixInput({ defaultValue, resultState = "success", + showLabel = true, }: { defaultValue: string; resultState?: "success" | "error"; + showLabel?: boolean; }) { const [value, setValue] = useState(defaultValue); const [status, setStatus] = useState< @@ -235,7 +237,11 @@ function WorkersSuffixInput({ : undefined; return ( - + Date: Tue, 24 Mar 2026 16:06:00 +0000 Subject: [PATCH 13/28] fix(docs): remove label only from InputGroup HomeGrid example --- packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx | 4 ++-- .../kumo-docs-astro/src/components/demos/InputGroupDemo.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index 8f45b48407..1906dfd8a6 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -38,7 +38,7 @@ import { useKumoToastManager, } from "@cloudflare/kumo"; import { ShikiProvider, CodeHighlighted } from "@cloudflare/kumo/code"; -import { InputGroupHeroDemo } from "~/components/demos/InputGroupDemo"; +import { WorkersSuffixInput } from "~/components/demos/InputGroupDemo"; import { MagnifyingGlassIcon, PlusIcon, @@ -423,7 +423,7 @@ export function HomeGrid() { { name: "InputGroup", id: "input-group", - Component: , + Component: , }, { name: "Meter", diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx index 0df8f9429d..cb78ad8fa7 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -21,7 +21,7 @@ const demoInputProps = { /** Workers URL with inline suffix — validates on edit with spinner then success */ export function InputGroupHeroDemo() { - return ; + return ; } /** Icon addons in various positions: start-only, end-only, and both */ @@ -202,7 +202,7 @@ export function InputGroupSuffixDemo() { } /** Shared Workers suffix input with validation spinner and success icon */ -function WorkersSuffixInput({ +export function WorkersSuffixInput({ defaultValue, resultState = "success", showLabel = true, From d01312b6ccda13f1f0fc0af1106f489e63875769 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Fri, 27 Mar 2026 19:29:53 +0000 Subject: [PATCH 14/28] fix: restore legacy InputGroup export, fix addon padding for xs/sm sizes, fix demo lint errors --- .../src/components/demos/InputGroupDemo.tsx | 28 +-- .../src/components/input-group/context.ts | 34 ++-- .../input-group/input-group-addon.tsx | 6 +- .../input-group/input-group.test.tsx | 50 ++++- packages/kumo/src/components/input/index.ts | 17 +- .../kumo/src/components/input/input-group.tsx | 177 ++++++++++++++++++ .../src/components/pagination/pagination.tsx | 5 + packages/kumo/src/index.ts | 84 +++++---- 8 files changed, 322 insertions(+), 79 deletions(-) create mode 100644 packages/kumo/src/components/input/input-group.tsx diff --git a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx index cb78ad8fa7..4690917eb7 100644 --- a/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -30,7 +30,7 @@ export function InputGroupIconsDemo() {
- + @@ -59,6 +60,7 @@ export function InputGroupTextDemo() { @example.com @@ -89,6 +91,7 @@ export function InputGroupButtonsDemo() { type={show ? "text" : "password"} defaultValue="password" aria-label="Password" + className="keeper-ignore" {...demoInputProps} /> @@ -133,7 +136,7 @@ export function InputGroupTooltipButtonDemo() { return ( - + - + - +
@@ -252,17 +255,17 @@ export function WorkersSuffixInput({ .workers.dev {status === "loading" && ( - + )} {status === "success" && ( - + )} {status === "error" && ( - + )}
@@ -275,28 +278,28 @@ export function InputGroupSizesDemo() {
- + - + - + - + @@ -324,7 +327,7 @@ export function InputGroupStatesDemo() { - + @@ -337,6 +340,7 @@ export function InputGroupStatesDemo() { diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts index 1c7daca57d..92fd9cd651 100644 --- a/packages/kumo/src/components/input-group/context.ts +++ b/packages/kumo/src/components/input-group/context.ts @@ -9,7 +9,7 @@ import type { FieldProps } from "../field/field"; // // Input outer: px-3 (12px base) — full padding when at the edge // Input seam: pl-2 / pr-2 (8px base) — applied by container has-[] -// Addon outer: px-2 (8px base) — on the container-edge side +// Addon outer: pl-2 / pr-2 (8px base) — on the container-edge side // Addon seam: nothing — input owns the gap entirely // // has-[] rules on the container override [&_input]:pl-{seam} when a start @@ -18,8 +18,16 @@ import type { FieldProps } from "../field/field"; export interface InputGroupSizeTokens { /** Full outer padding — matches standalone Input (e.g. px-3). */ inputOuter: string; - /** Outer padding for icon/text Addon at the container edge. */ - addonOuter: string; + /** + * Directional outer padding for Addon at the container edge. + * + * These MUST be static pl-/pr- strings (not derived at runtime via + * `"px-N".replace(…)`) so Tailwind JIT can detect them during its + * source-file scan. Dynamic string construction produces class names + * that never appear as literals, so Tailwind never generates the CSS. + */ + addonOuterStart: string; + addonOuterEnd: string; /** pr- for suffix when no end addon. */ suffixPad: string; fontSize: string; @@ -30,28 +38,32 @@ export interface InputGroupSizeTokens { export const INPUT_GROUP_SIZE: Record = { xs: { inputOuter: "px-1.5", - addonOuter: "px-1.5", + addonOuterStart: "pl-1.5", + addonOuterEnd: "pr-1.5", suffixPad: "pr-1.5", fontSize: "text-xs", iconSize: 10, }, sm: { inputOuter: "px-2", - addonOuter: "px-1.5", + addonOuterStart: "pl-1.5", + addonOuterEnd: "pr-1.5", suffixPad: "pr-2", fontSize: "text-xs", iconSize: 13, }, base: { inputOuter: "px-3", - addonOuter: "px-2", + addonOuterStart: "pl-2", + addonOuterEnd: "pr-2", suffixPad: "pr-3", fontSize: "text-base", iconSize: 18, }, lg: { inputOuter: "px-4", - addonOuter: "px-2.5", + addonOuterStart: "pl-2.5", + addonOuterEnd: "pr-2.5", suffixPad: "pr-4", fontSize: "text-base", iconSize: 20, @@ -83,14 +95,6 @@ export const INPUT_GROUP_HAS_CLASSES: Record = { ].join(" "), }; -// Derive directional padding from a symmetric "px-N" token. -export function pl(px: string): string { - return px.replace("px-", "pl-"); -} -export function pr(px: string): string { - return px.replace("px-", "pr-"); -} - // Context export interface InputGroupRootProps diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx index 596a174700..ccf7b1ec70 100644 --- a/packages/kumo/src/components/input-group/input-group-addon.tsx +++ b/packages/kumo/src/components/input-group/input-group-addon.tsx @@ -7,7 +7,7 @@ import { type ReactNode, } from "react"; import { cn } from "../../utils/cn"; -import { InputGroupContext, INPUT_GROUP_SIZE, pl, pr } from "./context"; +import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; import { Button } from "./input-group-button"; export interface InputGroupAddonProps { @@ -57,8 +57,8 @@ export function Addon({ tokens.fontSize, "*:pointer-events-auto", align === "start" - ? cn("-order-1", pl(tokens.addonOuter), "pr-0") - : cn("order-1", "pl-0", pr(tokens.addonOuter)), + ? cn("-order-1", tokens.addonOuterStart, "pr-0") + : cn("order-1", "pl-0", tokens.addonOuterEnd), className, )} > diff --git a/packages/kumo/src/components/input-group/input-group.test.tsx b/packages/kumo/src/components/input-group/input-group.test.tsx index e4c76b1a6d..3c20c4f0a7 100644 --- a/packages/kumo/src/components/input-group/input-group.test.tsx +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { InputGroup } from "./input-group"; +import { INPUT_GROUP_SIZE } from "./context"; +import type { KumoInputSize } from "../input/input"; describe("InputGroup", () => { describe("rendering", () => { @@ -249,6 +251,50 @@ describe("InputGroup", () => { ); expect(screen.getByRole("textbox")).toBeTruthy(); }); + + // Regression test: addon padding tokens must be static pl-/pr- strings + // so Tailwind JIT can detect them. Dynamic "px-N".replace() broke xs/sm. + it.each(["xs", "sm", "base", "lg"] as const)( + "start addon has correct padding class for size %s", + (size: KumoInputSize) => { + render( + + Icon + + , + ); + + const addon = screen.getByText("Icon").closest("[data-slot]")!; + const expectedClass = INPUT_GROUP_SIZE[size].addonOuterStart; + expect(addon.className).toContain(expectedClass); + }, + ); + + it.each(["xs", "sm", "base", "lg"] as const)( + "end addon has correct padding class for size %s", + (size: KumoInputSize) => { + render( + + + Icon + , + ); + + const addon = screen.getByText("Icon").closest("[data-slot]")!; + const expectedClass = INPUT_GROUP_SIZE[size].addonOuterEnd; + expect(addon.className).toContain(expectedClass); + }, + ); + + // Ensure all addonOuter tokens are static directional classes + // (not symmetric px- that would need runtime string replacement) + it("all addon tokens use static pl-/pr- classes (not px-)", () => { + for (const size of ["xs", "sm", "base", "lg"] as const) { + const tokens = INPUT_GROUP_SIZE[size]; + expect(tokens.addonOuterStart).toMatch(/^pl-/); + expect(tokens.addonOuterEnd).toMatch(/^pr-/); + } + }); }); describe("accessibility", () => { @@ -291,7 +337,9 @@ describe("InputGroup", () => { - ? + + ? + , ); diff --git a/packages/kumo/src/components/input/index.ts b/packages/kumo/src/components/input/index.ts index 8201d351e9..daa55ef7b8 100644 --- a/packages/kumo/src/components/input/index.ts +++ b/packages/kumo/src/components/input/index.ts @@ -1,11 +1,14 @@ export { Input, inputVariants, type InputProps } from "./input"; export { InputArea, Textarea, type InputAreaProps } from "./input-area"; -// Re-export InputGroup from its own directory for backwards compatibility +/** + * Legacy InputGroup — available via `@cloudflare/kumo/components/input`. + * For the new InputGroup with Addon, Suffix, and Field integration, + * import from `@cloudflare/kumo` or `@cloudflare/kumo/components/input-group`. + */ export { InputGroup, - type InputGroupRootProps, - type InputGroupInputProps, - type InputGroupButtonProps, - type InputGroupAddonProps, - type InputGroupSuffixProps, -} from "../input-group"; + type KumoInputGroupFocusMode, + type KumoInputGroupVariantsProps, + KUMO_INPUT_GROUP_VARIANTS, + KUMO_INPUT_GROUP_DEFAULT_VARIANTS, +} from "./input-group"; diff --git a/packages/kumo/src/components/input/input-group.tsx b/packages/kumo/src/components/input/input-group.tsx new file mode 100644 index 0000000000..c561f12b26 --- /dev/null +++ b/packages/kumo/src/components/input/input-group.tsx @@ -0,0 +1,177 @@ +/** + * @deprecated This is the legacy InputGroup component. + * Use the new InputGroup from "@cloudflare/kumo" which includes + * Addon, Suffix, and Field integration. + * + * import { InputGroup } from "@cloudflare/kumo"; + */ + +import { type PropsWithChildren, useContext } from "react"; +import * as React from "react"; +import { cn } from "../../utils/cn"; +import { + Input as InputExternal, + type InputProps, + inputVariants, +} from "./input"; +import { type ButtonProps, Button as ButtonExternal } from "../button/button"; + +export const KUMO_INPUT_GROUP_VARIANTS = { + focusMode: { + container: { + classes: "", + description: "Focus indicator on container (default behavior)", + }, + individual: { + classes: "", + description: "Focus indicators on individual elements", + }, + }, +} as const; + +export const KUMO_INPUT_GROUP_DEFAULT_VARIANTS = { + focusMode: "container", +} as const; + +export type KumoInputGroupFocusMode = + keyof typeof KUMO_INPUT_GROUP_VARIANTS.focusMode; + +export interface KumoInputGroupVariantsProps { + focusMode?: KumoInputGroupFocusMode; +} + +interface InputGroupRootProps extends KumoInputGroupVariantsProps { + className?: string; + size?: "xs" | "sm" | "base" | "lg" | undefined; +} + +interface InputGroupContextValue extends InputGroupRootProps { + inputId: string; + descriptionId: string; +} + +const InputGroupContext = React.createContext( + null, +); + +function Root({ + size, + children, + className, + focusMode = KUMO_INPUT_GROUP_DEFAULT_VARIANTS.focusMode, +}: PropsWithChildren) { + const inputId = React.useId(); + const descriptionId = React.useId(); + const contextValue = React.useMemo( + () => ({ size, inputId, descriptionId, focusMode }), + [size, inputId, descriptionId, focusMode], + ); + + const isIndividualFocus = focusMode === "individual"; + + return ( + +
+ {children} +
+
+ ); +} + +function Label({ children }: PropsWithChildren<{}>) { + const context = useContext(InputGroupContext); + const isIndividualFocus = context?.focusMode === "individual"; + + return ( + + ); +} + +function Input(props: InputProps) { + const context = useContext(InputGroupContext); + const isIndividualFocus = context?.focusMode === "individual"; + + return ( + + ); +} + +function Description({ children }: PropsWithChildren<{}>) { + const context = useContext(InputGroupContext); + const isIndividualFocus = context?.focusMode === "individual"; + + return ( + + {children} + + ); +} + +function Button({ + children, + className, + ...props +}: PropsWithChildren) { + const context = useContext(InputGroupContext); + const isIndividualFocus = context?.focusMode === "individual"; + + return ( + + {children} + + ); +} + +export const InputGroup = Object.assign(Root, { + Label, + Input, + Button, + Description, +}); diff --git a/packages/kumo/src/components/pagination/pagination.tsx b/packages/kumo/src/components/pagination/pagination.tsx index b97f92fad0..a512aed493 100644 --- a/packages/kumo/src/components/pagination/pagination.tsx +++ b/packages/kumo/src/components/pagination/pagination.tsx @@ -6,6 +6,11 @@ import { useState, type ReactNode, } from "react"; +/** + * @deprecated Using legacy InputGroup from input folder. + * Migrate to: import { InputGroup } from "@cloudflare/kumo"; + * which uses the new InputGroup component with Addon, Suffix, and Field integration. + */ import { InputGroup } from "../input"; import { CaretDoubleLeftIcon, diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 95ee89741c..0941959842 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -86,12 +86,14 @@ export { InputArea, Textarea, type InputAreaProps, +} from "./components/input"; +export { InputGroup, type InputGroupRootProps, type InputGroupAddonProps, type InputGroupSuffixProps, type InputGroupInputProps, -} from "./components/input"; +} from "./components/input-group"; export { LayerCard } from "./components/layer-card"; export { DeleteResource, @@ -220,46 +222,46 @@ export { // Sidebar export { - Sidebar, - SidebarProvider, - SidebarRoot, - SidebarHeader, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuSub, - SidebarMenuSubItem, - SidebarMenuSubButton, - SidebarSeparator, - SidebarInput, - SidebarTrigger, - SidebarRail, - SidebarResizeHandle, - SidebarMenuChevron, - SidebarCollapsible, - SidebarCollapsibleTrigger, - SidebarCollapsibleContent, - useSidebar, - KUMO_SIDEBAR_VARIANTS, - KUMO_SIDEBAR_DEFAULT_VARIANTS, - KUMO_SIDEBAR_STYLING, - type SidebarSide, - type SidebarVariant, - type SidebarCollapsible as SidebarCollapsibleType, - type SidebarContextValue, - type SidebarProviderProps, - type SidebarRootProps, - type SidebarMenuButtonSize, - type SidebarMenuButtonProps, - type SidebarMenuSubButtonProps, - type SidebarInputProps, + Sidebar, + SidebarProvider, + SidebarRoot, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarSeparator, + SidebarInput, + SidebarTrigger, + SidebarRail, + SidebarResizeHandle, + SidebarMenuChevron, + SidebarCollapsible, + SidebarCollapsibleTrigger, + SidebarCollapsibleContent, + useSidebar, + KUMO_SIDEBAR_VARIANTS, + KUMO_SIDEBAR_DEFAULT_VARIANTS, + KUMO_SIDEBAR_STYLING, + type SidebarSide, + type SidebarVariant, + type SidebarCollapsible as SidebarCollapsibleType, + type SidebarContextValue, + type SidebarProviderProps, + type SidebarRootProps, + type SidebarMenuButtonSize, + type SidebarMenuButtonProps, + type SidebarMenuSubButtonProps, + type SidebarInputProps, } from "./components/sidebar"; // PLOP_INJECT_EXPORT From 20d623dd7e38b2745f9e9fbef8de9a065513deef Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Fri, 27 Mar 2026 19:54:33 +0000 Subject: [PATCH 15/28] chore: minimize index.ts diff, trim changeset, remove review file --- .changeset/feat-input-group-revamp.md | 60 ++---- input-group-review.md | 282 -------------------------- packages/kumo/src/index.ts | 80 ++++---- 3 files changed, 55 insertions(+), 367 deletions(-) delete mode 100644 input-group-review.md diff --git a/.changeset/feat-input-group-revamp.md b/.changeset/feat-input-group-revamp.md index e34a1002f0..3f5f722f32 100644 --- a/.changeset/feat-input-group-revamp.md +++ b/.changeset/feat-input-group-revamp.md @@ -3,54 +3,24 @@ "@cloudflare/kumo-docs-astro": patch --- -feat(InputGroup): new compound component with Addon, Suffix, and Button support +Add `InputGroup` compound component for building decorated inputs -New `InputGroup` compound component for building inputs with icons, addons, inline suffixes, and action buttons. - -## Features - -- Field Integration — InputGroup accepts `label`, `description`, `error`, `required`, and `labelTooltip` props directly; automatically wraps in Field when label is provided -- Addons — Place icons before the input using `align="start"` -- Compact Button — Icon button inside an Addon for secondary actions (i.e. clear, toggle visibility, tooltip) -- Inline Suffix — Text that flows seamlessly next to the typed value (e.g., `.workers.dev`); input width adjusts automatically as user types -- Size Variants — `xs`, `sm`, `base`, `lg` sizes cascade to all children via context -- Error State — Error flows through context; `InputGroup.Input` auto-sets `aria-invalid="true"` when error is present -- Disabled State — `disabled` prop disables all interactive children - -## Sub-components - -| Component | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | -| `InputGroup` | Root container; provides context and accepts Field props | -| `InputGroup.Input` | Styled input; inherits `size`, `disabled`, `error` from context | -| `InputGroup.Addon` | Container for icons, text, or compact buttons; `align="start"` (default) or `align="end"` | -| `InputGroup.Button` | Full-height button (direct child) or compact button (inside Addon) | -| `InputGroup.Suffix` | Inline text suffix with automatic width measurement | - -## Usage +- Compose inputs with icons, text addons, inline suffixes, and ghost buttons via `InputGroup.Addon`, `.Suffix`, and `.Button` +- Built-in Field integration — pass `label`, `description`, `error`, and `labelTooltip` directly +- Size variants (`xs`, `sm`, `base`, `lg`) cascade to all children via context +- `InputGroup.Button` supports a `tooltip` prop that auto-derives `aria-label` +- Error and disabled states flow through context to all sub-components ```tsx -// With Field props - - - @example.com - - -// Inline suffix - - - .workers.dev - - -// With action button - - + + + + - Search + + + + + ``` diff --git a/input-group-review.md b/input-group-review.md deleted file mode 100644 index 84ce0af44a..0000000000 --- a/input-group-review.md +++ /dev/null @@ -1,282 +0,0 @@ -# Code Review for PR #249: InputGroup Compound Component - -## Summary - -Great work on this compound component! The API design is clean and the Field integration is well thought out. I found one significant bug causing layout flicker, plus a few minor documentation issues. - ---- - -## Bug: Layout flicker on initial render with Suffix - -**Severity: Major** - -**Files:** - -- `packages/kumo/src/components/input-group/input-group-suffix.tsx:25` -- `packages/kumo/src/components/input-group/input-group-addon.tsx:42` -- `packages/kumo/src/components/input-group/input-group.tsx:170-195` -- `packages/kumo/src/components/input-group/input-group-input.tsx:23-44` - -**Problem:** When an InputGroup contains a Suffix, there's a visible flash on page load where the input expands to full width, then snaps to fit its content. This happens because: - -1. Suffix and Addon use `useEffect` to register themselves with context -2. The parent's measurement logic runs in `useLayoutEffect` but depends on `hasSuffix` state -3. Since `useEffect` runs after paint, the first frame renders without suffix detection - -**Suggested fix:** The component already uses `:has()` selectors with `data-slot` attributes for addon padding adjustments. The same pattern can replace the JS measurement entirely using CSS `field-sizing: content`: - -```tsx -// In input-group.tsx container className: -"has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]", -"has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full", -"has-[[data-slot=input-group-suffix]]:[&_input]:grow-0", -"has-[[data-slot=input-group-suffix]]:[&_input]:pr-0", -``` - -This eliminates: - -- The ghost element measurement hack -- `registerInline`/`unregisterInline` context callbacks -- `hasSuffix` and `inputValue` state tracking -- The `useLayoutEffect` measurement logic in Root -- The `useEffect` registration in Suffix and Addon - -Browser support for `field-sizing` is ~80% (Chrome 123+, Safari 26.2+). Firefox doesn't support it yet, but the fallback (input stays wider) is acceptable progressive enhancement - no flicker, just less precise sizing. - ---- - -## Documentation Issues - -**Severity: Minor** - -1. **`packages/kumo-docs-astro/src/pages/components/input-group.astro:24`** - Incorrect `sourceFile` prop: - - ```diff - - sourceFile="components/input" - + sourceFile="components/input-group" - ``` - -2. **`packages/kumo-docs-astro/src/pages/components/input-group.astro:48-49`** - Granular import path is wrong: - ```diff - - import { InputGroup } from "@cloudflare/kumo/components/input"; - + import { InputGroup } from "@cloudflare/kumo/components/input-group"; - ``` - ---- - -## Overall - -**Minor Comments** - The core implementation is solid. The flicker bug should be addressed before merge since it's user-visible. The CSS-only approach would also simplify the codebase significantly by removing ~50 lines of measurement logic. - ---- - -## Suggested Diff - -Here's a working implementation of the CSS-only approach: - -```diff -diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx -index 27ee9c62..bd93cb1f 100644 ---- a/packages/kumo/src/components/input-group/input-group-addon.tsx -+++ b/packages/kumo/src/components/input-group/input-group-addon.tsx -@@ -2,7 +2,6 @@ import { - Children, - cloneElement, - useContext, -- useEffect, - useMemo, - isValidElement, - type ReactElement, -@@ -34,15 +33,6 @@ export function Addon({ - - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; -- const hasInline = context?.hasSuffix; -- -- const registerAddon = context?.registerAddon; -- const unregisterAddon = context?.unregisterAddon; -- -- useEffect(() => { -- registerAddon?.(align); -- return () => unregisterAddon?.(align); -- }, [align, registerAddon, unregisterAddon]); - - const addonContext = useMemo( - () => (context ? { ...context, insideAddon: true } : null), -@@ -61,9 +51,7 @@ export function Addon({ - }); - }); - -- // In inline mode (Suffix present), addons overlay as absolute elements -- // since the grid layout is reserved for the input + suffix measurement. -- // In standard flex mode, addons are flow-based flex items. -+ // Always use flex-based positioning. CSS order controls visual placement. - return ( - -
-diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx -index 3710f02e..34e7fb55 100644 ---- a/packages/kumo/src/components/input-group/input-group-input.tsx -+++ b/packages/kumo/src/components/input-group/input-group-input.tsx -@@ -1,5 +1,4 @@ --import { useCallback, useContext, useLayoutEffect } from "react"; --import type { ChangeEvent } from "react"; -+import { useContext } from "react"; - import { cn } from "../../utils/cn"; - import { Input as InputExternal, type InputProps } from "../input/input"; - import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; -@@ -20,29 +19,6 @@ export function Input(props: InputGroupInputProps) { - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; - -- // Track input value in context so Suffix can measure it -- const handleChange = useCallback( -- ( -- e: ChangeEvent & { -- preventBaseUIHandler: () => void; -- }, -- ) => { -- context?.setInputValue(e.target.value); -- props.onChange?.(e); -- }, -- [context?.setInputValue, props.onChange], -- ); -- -- // Sync controlled/default value into context before paint -- // so the Suffix ghost measurement is accurate on first render. -- useLayoutEffect(() => { -- if (props.value !== undefined) { -- context?.setInputValue(String(props.value)); -- } else if (props.defaultValue !== undefined) { -- context?.setInputValue(String(props.defaultValue)); -- } -- }, [props.value, props.defaultValue, context?.setInputValue]); -- - // Auto-set aria-invalid when error is present in context - const hasError = Boolean(context?.error); - -@@ -53,19 +29,13 @@ export function Input(props: InputGroupInputProps) { - disabled={context?.disabled || props.disabled} - aria-invalid={hasError || props["aria-invalid"]} - {...props} -- onChange={handleChange} - className={cn( -- "flex h-full min-w-0 items-center rounded-none border-0 font-sans", -+ "flex h-full min-w-0 grow items-center rounded-none border-0 bg-transparent font-sans", - // Always use full outer padding — the container's has-[] rules reduce - // pl/pr to inputSeam on sides that touch an addon. - tokens.inputOuter, -- context?.hasSuffix -- ? cn( -- "bg-transparent! overflow-hidden transition-none", -- // In inline mode the suffix owns its side — drop that padding. -- "pr-0!", -- ) -- : "grow bg-transparent", -+ // Truncate with ellipsis when text overflows -+ "text-ellipsis", - "ring-0! shadow-none focus:ring-0!", - props.className, - )} -diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx -index 5c74ea30..879dfea2 100644 ---- a/packages/kumo/src/components/input-group/input-group-suffix.tsx -+++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx -@@ -1,4 +1,4 @@ --import { useContext, useEffect, type ReactNode } from "react"; -+import { useContext, type ReactNode } from "react"; - import { cn } from "../../utils/cn"; - import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; - -@@ -11,7 +11,7 @@ export interface InputGroupSuffixProps { - - /** - * Inline suffix that flows seamlessly next to the typed input value. -- * Input width adjusts automatically as the user types. -+ * Input width adjusts automatically via CSS field-sizing: content. - */ - export function Suffix({ className, children }: InputGroupSuffixProps) { - const context = useContext(InputGroupContext); -@@ -19,21 +19,13 @@ export function Suffix({ className, children }: InputGroupSuffixProps) { - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; - -- const registerInline = context?.registerInline; -- const unregisterInline = context?.unregisterInline; -- -- useEffect(() => { -- registerInline?.(); -- return () => unregisterInline?.(); -- }, [registerInline, unregisterInline]); -- - return ( -
-diff --git a/packages/kumo/src/components/input-group/input-group.tsx b/packages/kumo/src/components/input-group/input-group.tsx -index 32e74282..42ad5c83 100644 ---- a/packages/kumo/src/components/input-group/input-group.tsx -+++ b/packages/kumo/src/components/input-group/input-group.tsx -@@ -196,16 +196,6 @@ const Root = forwardRef>( - - const container = ( - -- {/* Ghost element for suffix width measurement */} -- {hasSuffix && ( --
--
-- )} -
>( - ], - inputVariants({ size }), - "px-0", -- hasSuffix -- ? "grid grid-cols-[auto_1fr] items-center gap-0" -- : "flex items-center gap-0", -+ "flex items-center gap-0", -+ // CSS-only input sizing when suffix present: shrink to content, strip right padding -+ "has-[[data-slot=input-group-suffix]]:[&_input]:[field-sizing:content]", -+ "has-[[data-slot=input-group-suffix]]:[&_input]:max-w-full", -+ "has-[[data-slot=input-group-suffix]]:[&_input]:grow-0", -+ "has-[[data-slot=input-group-suffix]]:[&_input]:pr-0", - INPUT_GROUP_HAS_CLASSES[size], - className, - )} -``` - -Note: After applying this diff, the context can also be cleaned up to remove `hasSuffix`, `inputValue`, `setInputValue`, `registerInline`, `unregisterInline`, `registerAddon`, `unregisterAddon`, `hasStartAddon`, `hasEndAddon`, and the `ghostRef` in `input-group.tsx`. diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 0941959842..fbfa716c49 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -222,46 +222,46 @@ export { // Sidebar export { - Sidebar, - SidebarProvider, - SidebarRoot, - SidebarHeader, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuSub, - SidebarMenuSubItem, - SidebarMenuSubButton, - SidebarSeparator, - SidebarInput, - SidebarTrigger, - SidebarRail, - SidebarResizeHandle, - SidebarMenuChevron, - SidebarCollapsible, - SidebarCollapsibleTrigger, - SidebarCollapsibleContent, - useSidebar, - KUMO_SIDEBAR_VARIANTS, - KUMO_SIDEBAR_DEFAULT_VARIANTS, - KUMO_SIDEBAR_STYLING, - type SidebarSide, - type SidebarVariant, - type SidebarCollapsible as SidebarCollapsibleType, - type SidebarContextValue, - type SidebarProviderProps, - type SidebarRootProps, - type SidebarMenuButtonSize, - type SidebarMenuButtonProps, - type SidebarMenuSubButtonProps, - type SidebarInputProps, + Sidebar, + SidebarProvider, + SidebarRoot, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarSeparator, + SidebarInput, + SidebarTrigger, + SidebarRail, + SidebarResizeHandle, + SidebarMenuChevron, + SidebarCollapsible, + SidebarCollapsibleTrigger, + SidebarCollapsibleContent, + useSidebar, + KUMO_SIDEBAR_VARIANTS, + KUMO_SIDEBAR_DEFAULT_VARIANTS, + KUMO_SIDEBAR_STYLING, + type SidebarSide, + type SidebarVariant, + type SidebarCollapsible as SidebarCollapsibleType, + type SidebarContextValue, + type SidebarProviderProps, + type SidebarRootProps, + type SidebarMenuButtonSize, + type SidebarMenuButtonProps, + type SidebarMenuSubButtonProps, + type SidebarInputProps, } from "./components/sidebar"; // PLOP_INJECT_EXPORT From f062dc123987cd95de6cacacc7e0a3acbe6dcdc3 Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Fri, 27 Mar 2026 21:22:45 +0000 Subject: [PATCH 16/28] fix: clean up InputGroup dead CSS, add forwardRef, export missing type - Remove dead has-[[data-slot=input-group-button]] CSS selectors from context.ts - Add forwardRef to InputGroup.Input, InputGroup.Addon, and InputGroup.Suffix - Export missing InputGroupButtonProps from barrel index - Delete accidental empty dist.md from kumo-docs-astro --- packages/kumo-docs-astro/dist.md | 0 .../src/components/input-group/context.ts | 4 - .../input-group/input-group-addon.tsx | 80 +++++++++--------- .../input-group/input-group-input.tsx | 55 +++++++------ .../input-group/input-group-suffix.tsx | 41 +++++----- packages/kumo/src/index.ts | 81 ++++++++++--------- 6 files changed, 133 insertions(+), 128 deletions(-) delete mode 100644 packages/kumo-docs-astro/dist.md diff --git a/packages/kumo-docs-astro/dist.md b/packages/kumo-docs-astro/dist.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts index 92fd9cd651..78ef9592e2 100644 --- a/packages/kumo/src/components/input-group/context.ts +++ b/packages/kumo/src/components/input-group/context.ts @@ -76,22 +76,18 @@ export const INPUT_GROUP_HAS_CLASSES: Record = { xs: [ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1", "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1", - "has-[[data-slot=input-group-button]]:[&_input]:pr-1", ].join(" "), sm: [ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1.5", "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1.5", - "has-[[data-slot=input-group-button]]:[&_input]:pr-1.5", ].join(" "), base: [ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2", "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2", - "has-[[data-slot=input-group-button]]:[&_input]:pr-2", ].join(" "), lg: [ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2.5", "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2.5", - "has-[[data-slot=input-group-button]]:[&_input]:pr-2.5", ].join(" "), }; diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx index ccf7b1ec70..3418f93d11 100644 --- a/packages/kumo/src/components/input-group/input-group-addon.tsx +++ b/packages/kumo/src/components/input-group/input-group-addon.tsx @@ -1,6 +1,7 @@ import { Children, cloneElement, + forwardRef, useContext, isValidElement, type ReactElement, @@ -23,47 +24,48 @@ export interface InputGroupAddonProps { * Container for icons, text, or compact buttons positioned at the start or end * of the input. Automatically sizes icon children to match the input size. */ -export function Addon({ - align = "start", - className, - children, -}: InputGroupAddonProps) { - const context = useContext(InputGroupContext); +export const Addon = forwardRef( + ({ align = "start", className, children }, ref) => { + const context = useContext(InputGroupContext); - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; - // Inject size into direct icon children that don't already have one set. - // Skips buttons (which have their own size handling) and non-element nodes. - const sizedChildren = Children.map(children, (child) => { - if (!isValidElement(child)) return child; - const props = child.props as { size?: unknown }; - if (props.size !== undefined) return child; - if (child.type === "button" || child.type === Button) return child; - return cloneElement(child as ReactElement<{ size?: number }>, { - size: tokens.iconSize, + // Inject size into direct icon children that don't already have one set. + // Skips buttons (which have their own size handling) and non-element nodes. + const sizedChildren = Children.map(children, (child) => { + if (!isValidElement(child)) return child; + const props = child.props as { size?: unknown }; + if (props.size !== undefined) return child; + if (child.type === "button" || child.type === Button) return child; + return cloneElement(child as ReactElement<{ size?: number }>, { + size: tokens.iconSize, + }); }); - }); - // Always use flex-based positioning. CSS order controls visual placement. - return ( -
- {sizedChildren} -
- ); -} + // Always use flex-based positioning. CSS order controls visual placement. + return ( +
+ {sizedChildren} +
+ ); + }, +); Addon.displayName = "InputGroup.Addon"; diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx index 56a16e3a7a..59938488c6 100644 --- a/packages/kumo/src/components/input-group/input-group-input.tsx +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -1,4 +1,4 @@ -import { useContext } from "react"; +import { forwardRef, useContext } from "react"; import { cn } from "../../utils/cn"; import { Input as InputExternal, type InputProps } from "../input/input"; import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; @@ -13,32 +13,35 @@ export type InputGroupInputProps = Omit< * Text input that inherits size, disabled, and error state from InputGroup context. * Automatically sets `aria-invalid` when parent has an error. */ -export function Input(props: InputGroupInputProps) { - const context = useContext(InputGroupContext); +export const Input = forwardRef( + (props, ref) => { + const context = useContext(InputGroupContext); - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; - // Auto-set aria-invalid when error is present in context - const hasError = Boolean(context?.error); + // Auto-set aria-invalid when error is present in context + const hasError = Boolean(context?.error); - return ( - - ); -} + return ( + + ); + }, +); Input.displayName = "InputGroup.Input"; diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx index 55b29a6d5a..26b37b3295 100644 --- a/packages/kumo/src/components/input-group/input-group-suffix.tsx +++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx @@ -1,4 +1,4 @@ -import { useContext, type ReactNode } from "react"; +import { forwardRef, useContext, type ReactNode } from "react"; import { cn } from "../../utils/cn"; import { InputGroupContext, INPUT_GROUP_SIZE } from "./context"; @@ -13,24 +13,27 @@ export interface InputGroupSuffixProps { * Inline suffix that flows seamlessly next to the typed input value. * Input width adjusts automatically via CSS `field-sizing: content`. */ -export function Suffix({ className, children }: InputGroupSuffixProps) { - const context = useContext(InputGroupContext); +export const Suffix = forwardRef( + ({ className, children }, ref) => { + const context = useContext(InputGroupContext); - const size = context?.size ?? "base"; - const tokens = INPUT_GROUP_SIZE[size]; + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; - return ( -
- {children} -
- ); -} + return ( +
+ {children} +
+ ); + }, +); Suffix.displayName = "InputGroup.Suffix"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index fbfa716c49..9e32ba30e8 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -93,6 +93,7 @@ export { type InputGroupAddonProps, type InputGroupSuffixProps, type InputGroupInputProps, + type InputGroupButtonProps, } from "./components/input-group"; export { LayerCard } from "./components/layer-card"; export { @@ -222,46 +223,46 @@ export { // Sidebar export { - Sidebar, - SidebarProvider, - SidebarRoot, - SidebarHeader, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuSub, - SidebarMenuSubItem, - SidebarMenuSubButton, - SidebarSeparator, - SidebarInput, - SidebarTrigger, - SidebarRail, - SidebarResizeHandle, - SidebarMenuChevron, - SidebarCollapsible, - SidebarCollapsibleTrigger, - SidebarCollapsibleContent, - useSidebar, - KUMO_SIDEBAR_VARIANTS, - KUMO_SIDEBAR_DEFAULT_VARIANTS, - KUMO_SIDEBAR_STYLING, - type SidebarSide, - type SidebarVariant, - type SidebarCollapsible as SidebarCollapsibleType, - type SidebarContextValue, - type SidebarProviderProps, - type SidebarRootProps, - type SidebarMenuButtonSize, - type SidebarMenuButtonProps, - type SidebarMenuSubButtonProps, - type SidebarInputProps, + Sidebar, + SidebarProvider, + SidebarRoot, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarSeparator, + SidebarInput, + SidebarTrigger, + SidebarRail, + SidebarResizeHandle, + SidebarMenuChevron, + SidebarCollapsible, + SidebarCollapsibleTrigger, + SidebarCollapsibleContent, + useSidebar, + KUMO_SIDEBAR_VARIANTS, + KUMO_SIDEBAR_DEFAULT_VARIANTS, + KUMO_SIDEBAR_STYLING, + type SidebarSide, + type SidebarVariant, + type SidebarCollapsible as SidebarCollapsibleType, + type SidebarContextValue, + type SidebarProviderProps, + type SidebarRootProps, + type SidebarMenuButtonSize, + type SidebarMenuButtonProps, + type SidebarMenuSubButtonProps, + type SidebarInputProps, } from "./components/sidebar"; // PLOP_INJECT_EXPORT From 7185c9ee4893d961a483d0d14aa673262815afaf Mon Sep 17 00:00:00 2001 From: Pedro Menezes Date: Sat, 28 Mar 2026 02:16:36 +0000 Subject: [PATCH 17/28] refactor: replace InputGroup imperative focus logic with native label element - Change InputGroup container from
to