diff --git a/.changeset/feat-input-group-revamp.md b/.changeset/feat-input-group-revamp.md new file mode 100644 index 0000000000..25b9d68fc1 --- /dev/null +++ b/.changeset/feat-input-group-revamp.md @@ -0,0 +1,46 @@ +--- +"@cloudflare/kumo": minor +"@cloudflare/kumo-docs-astro": patch +--- + +Add `InputGroup` compound component for composing decorated inputs + +Compound structure: `InputGroup`, `InputGroup.Input`, `InputGroup.Addon`, `InputGroup.Suffix`, `InputGroup.Button`. + +- Field integration — pass `label`, `description`, `error`, `required`, and `labelTooltip` directly to `InputGroup` +- Size variants (`xs`, `sm`, `base`, `lg`) propagate to all sub-components via context, including icon sizing in addons +- `InputGroup.Addon` — positions icons, text, or buttons at `align="start"` (default) or `align="end"` of the input +- `InputGroup.Suffix` — inline text suffix (e.g. `.workers.dev`) +- `InputGroup.Button` — ghost button for secondary actions with tooltip support +- Deprecated `InputGroup.Label` — use `InputGroup.Addon` instead +- Deprecated `InputGroup.Description` — use `InputGroup.Suffix` instead + +```tsx +{/* Reveal / hide password */} + + + + setShow(!show)} + > + {show ? : } + + + +``` + +```tsx +{/* Search input */} + + + + + + +``` diff --git a/lint/no-primitive-colors.js b/lint/no-primitive-colors.js index 80a450881d..afc626bfee 100644 --- a/lint/no-primitive-colors.js +++ b/lint/no-primitive-colors.js @@ -74,6 +74,7 @@ const NON_COLOR_UTILITIES = new Set([ "current", "inherit", "none", + "auto", // Border utilities (not colors) "0", "2", diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 2ac78fe343..5f5cc90131 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 0befa83d01..20691ceb87 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -38,6 +38,7 @@ import { useKumoToastManager, } from "@cloudflare/kumo"; import { ShikiProvider, CodeHighlighted } from "@cloudflare/kumo/code"; +import { InputGroupDemo } from "~/components/demos/InputGroupDemo"; import { MagnifyingGlassIcon, PlusIcon, @@ -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", @@ -416,6 +418,11 @@ export function HomeGrid() { id: "input-area", Component: , }, + { + name: "InputGroup", + id: "input-group", + Component: , + }, { name: "Meter", id: "meter", 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..a05c85370b --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/InputGroupDemo.tsx @@ -0,0 +1,334 @@ +import { useRef, useState } from "react"; +import { InputGroup, Loader } from "@cloudflare/kumo"; +import { + MagnifyingGlassIcon, + CheckCircleIcon, + XCircleIcon, + EyeIcon, + EyeSlashIcon, + LinkIcon, + QuestionIcon, + XIcon, +} from "@phosphor-icons/react"; + +export function InputGroupDemo() { + const [status, setStatus] = useState<"idle" | "loading" | "success">( + "success", + ); + const [value, setValue] = useState("kumo"); + 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("success"), 1500); + } else { + setStatus("idle"); + } + }; + + return ( +
+ + + .workers.dev + {status !== "idle" && ( + + {status === "loading" ? ( + + ) : ( + + )} + + )} + +
+ ); +} + +export function InputGroupIconsDemo() { + return ( + + + + + + + ); +} + +export function InputGroupTextDemo() { + return ( +
+ + @ + + + + + + @example.com + + + + /api/ + + .json + +
+ ); +} + +export function InputGroupButtonsDemo() { + const [show, setShow] = useState(false); + const [searchValue, setSearchValue] = useState("search"); + + return ( +
+ + + + setShow(!show)} + /> + + + + + + + + setSearchValue(e.target.value)} + /> + {searchValue && ( + + setSearchValue("")} + > + + + + )} + {}}> + Search + + +
+ ); +} + +export function InputGroupTooltipButtonDemo() { + return ( + + + + + + + {}} + /> + + + ); +} + +export function InputGroupKbdDemo() { + return ( + + + + + + + ⌘K + + + ); +} + +export function InputGroupLoadingDemo() { + return ( + + + + + + + ); +} + +export function InputGroupSuffixDemo() { + return ( +
+ + + .workers.dev + + + + + + + + .workers.dev + + + + +
+ ); +} + +export function InputGroupSizesDemo() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export function InputGroupStatesDemo() { + const [show, setShow] = useState(false); + + return ( +
+ + + @example.com + + + + + + + + + + + $ + + + + + + + setShow(!show)} + /> + + +
+ ); +} 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..efb591ac72 --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/input-group.mdx @@ -0,0 +1,326 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "InputGroup" +description: "Compose inputs with addons, icons, buttons, and text for rich form fields." +sourceFile: "components/input-group" +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import CodeBlock from "~/components/docs/CodeBlock.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + InputGroupDemo, + InputGroupIconsDemo, + InputGroupTextDemo, + InputGroupButtonsDemo, + InputGroupTooltipButtonDemo, + InputGroupKbdDemo, + InputGroupLoadingDemo, + InputGroupSuffixDemo, + InputGroupSizesDemo, + InputGroupStatesDemo, +} from "~/components/demos/InputGroupDemo"; + + + + + + + + + +## Installation + +### Barrel + + + +### Granular + + + + +{/* Usage */} + + + +## Usage + +### With Built-in Field (Recommended) + +

+ Pass the `label` prop to InputGroup to enable the built-in Field wrapper with + label, description, and error support. +

+ +```tsx +import { InputGroup } from "@cloudflare/kumo"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; + +export default function Example() { + return ( + + + + + + + ); +} +``` + +### Bare InputGroup (Custom Layouts) + +

+ For custom form layouts, use InputGroup without `label`. Must provide + `aria-label` on `InputGroup.Input` for accessibility. +

+ +```tsx +import { InputGroup } from "@cloudflare/kumo"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; + +export default function Example() { + return ( + + + + + + + ); +} +``` + +
+ + + +## Examples + +### Icon + +

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

+ + + + +### Text + +

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

+ + + + +### Button + +

+ Place `InputGroup.Button` inside an Addon for actions that operate directly on + the input value, such as reveal/hide or clear. +

+ + + + +### 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. +

+ + + + +### Kbd + +

Place a keyboard shortcut hint inside an end Addon.

+ + + + +### Loading + +

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

+ + + + +### Inline Suffix + +

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

+ + + + +### Sizes + +

+ Four sizes: `xs`, `sm`, `base` (default), and `lg`. The size applies to the + entire group. +

+ + + + +### States + +

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

+ + + +
+ + + +## 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.Button` + +

+ Button for secondary actions like toggle, copy, or help. Renders inside an + Addon. Pass a `tooltip` prop to show a tooltip on hover. +

+ + +### `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/lint/no-primitive-colors.js b/packages/kumo/lint/no-primitive-colors.js index 49f69e5151..27fd288d02 100644 --- a/packages/kumo/lint/no-primitive-colors.js +++ b/packages/kumo/lint/no-primitive-colors.js @@ -74,6 +74,7 @@ const NON_COLOR_UTILITIES = new Set([ "current", "inherit", "none", + "auto", // Border utilities (not colors) "0", "2", diff --git a/packages/kumo/package.json b/packages/kumo/package.json index 700e481a43..ed3d3cca92 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/index.ts b/packages/kumo/scripts/component-registry/index.ts index a268314c20..886e7f5e25 100644 --- a/packages/kumo/scripts/component-registry/index.ts +++ b/packages/kumo/scripts/component-registry/index.ts @@ -611,6 +611,20 @@ async function processComponent( let usageExamples: string[] | undefined; let renderElement: string | undefined; + // Inject additional props for sub-components (e.g., "InputGroup.Input") + const subAdditionalProps = + ADDITIONAL_COMPONENT_PROPS[`${config.name}.${subComp.name}`]; + if (subAdditionalProps) { + for (const [propName, propSchema] of Object.entries( + subAdditionalProps, + )) { + subProps[propName] = { + ...subProps[propName], + ...propSchema, + }; + } + } + if (subComp.isPassThrough && subComp.baseComponent) { const passthroughDoc = PASSTHROUGH_COMPONENT_DOCS[subComp.baseComponent]; diff --git a/packages/kumo/scripts/component-registry/metadata.ts b/packages/kumo/scripts/component-registry/metadata.ts index 71d39f43ef..ecf12d7b74 100644 --- a/packages/kumo/scripts/component-registry/metadata.ts +++ b/packages/kumo/scripts/component-registry/metadata.ts @@ -260,6 +260,45 @@ export const ADDITIONAL_COMPONENT_PROPS: Record< description: "Callback when checkbox value changes", }, }, + "InputGroup.Addon": { + align: { + type: '"start" | "end"', + description: "Position relative to the input.", + default: '"start"', + }, + className: { + type: "string", + description: "Additional CSS classes.", + }, + }, + "InputGroup.Button": { + tooltip: { + type: "ReactNode", + description: + "When provided, wraps the button in a Tooltip. Automatically sets aria-label from a string value.", + }, + tooltipSide: { + type: '"top" | "right" | "bottom" | "left"', + description: "Preferred side for the tooltip popup.", + default: '"bottom"', + }, + variant: { + type: '"primary" | "secondary" | "ghost" | "destructive" | "secondary-destructive" | "outline"', + description: "Button visual style. Defaults to ghost.", + default: '"ghost"', + }, + size: { + type: '"xs" | "sm" | "base" | "lg"', + description: "Button size.", + default: '"sm"', + }, + }, + "InputGroup.Suffix": { + className: { + type: "string", + description: "Additional CSS classes.", + }, + }, }; // ============================================================================= @@ -318,7 +357,12 @@ export const COMPONENT_STYLING_METADATA: Record = { ], }, ClipboardText: { - baseTokens: ["bg-kumo-base", "text-kumo-default", "ring-kumo-line", "border-kumo-fill"], + baseTokens: [ + "bg-kumo-base", + "text-kumo-default", + "ring-kumo-line", + "border-kumo-fill", + ], states: { input: ["bg-kumo-control", "text-kumo-default", "ring-kumo-line"], text: ["bg-kumo-base", "font-mono"], @@ -403,7 +447,12 @@ export const COMPONENT_STYLING_METADATA: Record = { }, }, Input: { - baseTokens: ["bg-kumo-control", "text-kumo-default", "text-kumo-subtle", "ring-kumo-line"], + baseTokens: [ + "bg-kumo-control", + "text-kumo-default", + "text-kumo-subtle", + "ring-kumo-line", + ], sizeVariants: { xs: { height: 20, @@ -473,7 +522,12 @@ export const COMPONENT_STYLING_METADATA: Record = { }, }, Dialog: { - baseTokens: ["bg-kumo-base", "text-kumo-default", "border-kumo-line", "shadow-m"], + baseTokens: [ + "bg-kumo-base", + "text-kumo-default", + "border-kumo-line", + "shadow-m", + ], sizeVariants: { sm: { height: 0, // Dialog height is auto (content-driven) 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..d0afa030bd --- /dev/null +++ b/packages/kumo/src/components/input-group/context.ts @@ -0,0 +1,257 @@ +import { + Children, + createContext, + isValidElement, + useContext, + type HTMLAttributes, + type ReactNode, +} 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: 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 +// 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; + /** + * 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; + /** + * Reduced outer padding when the Addon contains a Button. + * Buttons have their own internal padding, so the Addon can use + * less outer padding to keep the visual gap balanced. + */ + addonButtonOuterStart: string; + addonButtonOuterEnd: string; + /** pr- for suffix when no end addon. */ + suffixPad: string; + fontSize: string; + /** Icon size in px. */ + iconSize: number; +} + +export const INPUT_GROUP_SIZE: Record = { + xs: { + inputOuter: "px-1.5", + addonOuterStart: "pl-1.5", + addonOuterEnd: "pr-1.5", + addonButtonOuterStart: "pl-1", + addonButtonOuterEnd: "pr-1", + suffixPad: "pr-1.5", + fontSize: "text-xs", + iconSize: 10, + }, + sm: { + inputOuter: "px-2", + addonOuterStart: "pl-1.5", + addonOuterEnd: "pr-1.5", + addonButtonOuterStart: "pl-1", + addonButtonOuterEnd: "pr-1", + suffixPad: "pr-2", + fontSize: "text-xs", + iconSize: 13, + }, + base: { + inputOuter: "px-3", + addonOuterStart: "pl-2", + addonOuterEnd: "pr-2", + addonButtonOuterStart: "pl-1", + addonButtonOuterEnd: "pr-1", + suffixPad: "pr-3", + fontSize: "text-base", + iconSize: 18, + }, + lg: { + inputOuter: "px-4", + addonOuterStart: "pl-2.5", + addonOuterEnd: "pr-2.5", + addonButtonOuterStart: "pl-1.5", + addonButtonOuterEnd: "pr-1.5", + suffixPad: "pr-4", + 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", + ].join(" "), + sm: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1.5", + "has-[[data-slot=input-group-addon-end]]:[&_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", + ].join(" "), + lg: [ + "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2.5", + "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2.5", + ].join(" "), +}; + +// Context + +/** + * Props for `InputGroup.Root`. Focus mode is auto-detected from children + * (see `detectFocusMode`), so it is not part of the public or internal API. + */ +export interface InputGroupRootPropsInternal + extends HTMLAttributes, + Partial< + Pick< + FieldProps, + "label" | "description" | "error" | "required" | "labelTooltip" + > + > { + size?: KumoInputSize | undefined; + disabled?: boolean; +} + +/** Public InputGroup.Root props — identical to the internal type. */ +export type InputGroupRootProps = InputGroupRootPropsInternal; + +export interface InputGroupContextValue { + size?: KumoInputSize; + focusMode: "container" | "individual" | "hybrid"; + disabled: boolean; + error?: FieldProps["error"]; + /** Auto-generated id for the input element; used by the invisible label overlay. */ + inputId: string; +} + +export const InputGroupContext = createContext( + null, +); + +/** + * Set to `true` by `InputGroup.Addon` so that `InputGroup.Button` can detect + * whether it's wrapped in an Addon. Ghost buttons should always live inside + * an Addon for correct spacing. + */ +export const InputGroupAddonContext = createContext(false); + +/** + * Reads InputGroupContext and warns in development when the context is null + * (i.e. when a sub-component is rendered outside of ``). + */ +export function useInputGroupContext(componentName: string) { + const context = useContext(InputGroupContext); + if (process.env.NODE_ENV !== "production" && !context) { + console.warn( + ` must be used within . Falling back to default values.`, + ); + } + return context; +} + +/** + * Partitions InputGroup children for hybrid focus mode. + * + * Container zone: Addon, Input, Suffix, text nodes — everything that should + * share a single container-style border. + * + * Individual zone: Direct `InputGroup.Button` elements that manage their own + * border and focus ring. + * + * Uses `displayName` comparison to identify elements, avoiding circular + * imports between `context.ts` and the sub-component files. + */ +export function partitionChildren(children: ReactNode): { + containerZone: ReactNode[]; + individualZone: ReactNode[]; +} { + const containerZone: ReactNode[] = []; + const individualZone: ReactNode[] = []; + + Children.forEach(children, (child) => { + if ( + isValidElement(child) && + (child.type as { displayName?: string })?.displayName === + "InputGroup.Button" + ) { + individualZone.push(child); + } else { + containerZone.push(child); + } + }); + + return { containerZone, individualZone }; +} + +/** + * Analyzes the direct children of `InputGroup` to determine the focus mode. + * + * Returns `"hybrid"` when BOTH an `InputGroup.Addon` AND a non-ghost direct + * `InputGroup.Button` are present. In hybrid mode, Addon+Input share a + * container-style border while Buttons get individual borders. + * + * Returns `"individual"` when a non-ghost direct `InputGroup.Button` is + * present WITHOUT any `InputGroup.Addon`. This signals a toolbar/pagination + * layout where each element manages its own focus ring. + * + * Returns `"container"` (default) in all other cases — the container owns a + * single shared focus ring. + * + * Uses `displayName` comparison to identify elements, avoiding circular + * imports between `context.ts` and the sub-component files. + */ +export function detectFocusMode( + children: ReactNode, +): "container" | "individual" | "hybrid" { + let hasNonGhostDirectButton = false; + let hasAddon = false; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + + // Identify components by displayName to avoid circular imports. + const type = child.type; + const displayName = + typeof type === "function" || typeof type === "object" + ? (type as { displayName?: string }).displayName + : undefined; + + if (displayName === "InputGroup.Addon") { + hasAddon = true; + return; + } + + if (displayName !== "InputGroup.Button") return; + + // A direct-child Button is by definition NOT inside an Addon (Addon's + // children are children of the Addon element, not of InputGroup). + // Check whether the variant is explicitly non-ghost. + const variant = (child.props as { variant?: string }).variant; + if (variant !== undefined && variant !== "ghost") { + hasNonGhostDirectButton = true; + } + }); + + if (hasNonGhostDirectButton && hasAddon) return "hybrid"; + if (hasNonGhostDirectButton) return "individual"; + return "container"; +} 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..7b3cdc45fe --- /dev/null +++ b/packages/kumo/src/components/input-group/index.ts @@ -0,0 +1,10 @@ +export { + InputGroup, + KUMO_INPUT_GROUP_VARIANTS, + KUMO_INPUT_GROUP_DEFAULT_VARIANTS, + 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..e1e5c62245 --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-addon.tsx @@ -0,0 +1,93 @@ +import { + Children, + cloneElement, + forwardRef, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { cn } from "../../utils/cn"; +import { + useInputGroupContext, + INPUT_GROUP_SIZE, + InputGroupAddonContext, +} 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 const Addon = forwardRef( + ({ align = "start", className, children }, ref) => { + const context = useInputGroupContext("Addon"); + + 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. + // Also tracks whether a Button is present so we can reduce outer padding. + let containsButton = false; + const sizedChildren = Children.map(children, (child) => { + if (!isValidElement(child)) return child; + if (child.type === Button) { + containsButton = true; + return child; + } + const props = child.props as { size?: unknown }; + if (props.size !== undefined) return child; + return cloneElement(child as ReactElement<{ size?: number }>, { + size: tokens.iconSize, + }); + }); + + // 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-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx new file mode 100644 index 0000000000..8a4b48f60f --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-button.tsx @@ -0,0 +1,187 @@ +import React, { + 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 type { KumoInputSize } from "../input/input"; +import { + INPUT_GROUP_SIZE, + InputGroupAddonContext, + useInputGroupContext, +} from "./context"; + +/** + * In container mode, buttons render "one size down" so they stay visually + * subordinate to the input. In individual mode the size passes through + * unchanged (pagination / toolbar buttons should match the input height). + */ +const COMPACT_BUTTON_SIZE: Record = { + xs: "xs", + sm: "xs", + base: "sm", + lg: "base", +}; + +export type InputGroupButtonProps = ButtonProps & { + /** + * 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 for secondary actions rendered inside `InputGroup.Addon` + * (toggle, copy, help). + * + * In `focusMode="container"` (default), renders as a compact ghost button + * subordinate to the input. In `focusMode="individual"`, renders as a full + * standalone button with its own focus ring, matching toolbar/pagination usage. + * + * Pass a `tooltip` prop to show a tooltip on hover. + */ +export const Button = forwardRef< + HTMLButtonElement, + PropsWithChildren +>( + ( + { + children, + className, + variant, + size, + disabled, + tooltip, + tooltipSide = "bottom", + icon, + ...props + }: PropsWithChildren, + ref: React.ForwardedRef, + ) => { + const context = useInputGroupContext("Button"); + const isInsideAddon = useContext(InputGroupAddonContext); + const isDisabled = disabled ?? context?.disabled; + const isIndividual = + context?.focusMode === "individual" || context?.focusMode === "hybrid"; + const effectiveVariant = variant ?? "ghost"; + + if ( + process.env.NODE_ENV !== "production" && + context && + effectiveVariant === "ghost" && + !isInsideAddon + ) { + console.warn( + "InputGroup.Button: Ghost buttons should be wrapped in for correct spacing.", + ); + } + + if ( + process.env.NODE_ENV !== "production" && + context && + size !== undefined + ) { + console.warn( + "InputGroup.Button: Set `size` on instead of .", + ); + } + + // Derive aria-label from tooltip string when the button has no explicit label. + // Icon-only buttons require an aria-label for a11y. + const tooltipAriaLabel = + typeof tooltip === "string" && !props["aria-label"] ? tooltip : undefined; + + // Pre-render the icon with the context-derived size so it matches the + // Addon icon sizing (e.g. 18px at "base"). Without this, Button's + // internal renderIconNode renders `` with no size prop, + // falling back to CSS font-size (~14px). + const contextIconSize = context + ? INPUT_GROUP_SIZE[context.size ?? "base"].iconSize + : undefined; + + const sizedIcon = + icon && + contextIconSize && + (typeof icon === "function" || + (typeof icon === "object" && + icon !== null && + !React.isValidElement(icon))) + ? React.createElement(icon as React.ComponentType<{ size?: number }>, { + size: contextIconSize, + }) + : icon; + + 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 new file mode 100644 index 0000000000..479779749d --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-input.tsx @@ -0,0 +1,94 @@ +import { forwardRef } from "react"; +import { cn } from "../../utils/cn"; +import { Input as InputExternal, type InputProps } from "../input/input"; +import { useInputGroupContext, 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" | "disabled" +>; + +/** + * Text input that inherits size, disabled, and error state from InputGroup context. + * Automatically sets `aria-invalid` when parent has an error. + */ +export const Input = forwardRef( + (props, ref) => { + const context = useInputGroupContext("Input"); + + // Warn when props that belong on are passed directly + if (process.env.NODE_ENV !== "production" && context) { + if ((props as any).size !== undefined) { + console.warn( + "InputGroup.Input: Set `size` on instead of .", + ); + } + if ((props as any).disabled !== undefined) { + console.warn( + "InputGroup.Input: Set `disabled` on instead of .", + ); + } + if ((props as any).label !== undefined) { + console.warn( + "InputGroup.Input: Use the `label` prop on instead of .", + ); + } + if ((props as any).description !== undefined) { + console.warn( + "InputGroup.Input: Use instead of passing `description` to .", + ); + } + } + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + const isIndividual = context?.focusMode === "individual"; + + // Auto-set aria-invalid when error is present in context + const hasError = Boolean(context?.error); + + // Use explicit id if provided, otherwise fall back to context id + // (links the input to the invisible label overlay for click-to-focus). + const inputId = props.id ?? context?.inputId; + + 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..ee068722e7 --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx @@ -0,0 +1,39 @@ +import { forwardRef, type ReactNode } from "react"; +import { cn } from "../../utils/cn"; +import { useInputGroupContext, 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 via CSS `field-sizing: content`. + */ +export const Suffix = forwardRef( + ({ className, children }, ref) => { + const context = useInputGroupContext("Suffix"); + + const size = context?.size ?? "base"; + const tokens = INPUT_GROUP_SIZE[size]; + + 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..a7fbb3c8e4 --- /dev/null +++ b/packages/kumo/src/components/input-group/input-group.test.tsx @@ -0,0 +1,1316 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InputGroup } from "./input-group"; +import { INPUT_GROUP_SIZE, detectFocusMode } from "./context"; +import type { KumoInputSize } from "../input/input"; + +const MockIcon = (props: { size?: number }) => ( + +); + +const FakeButton = (props: { + variant?: string; + children?: React.ReactNode; +}) => ; + +describe("InputGroup", () => { + describe("rendering", () => { + it("renders input with addon", () => { + render( + + + + + + , + ); + expect(screen.getByTestId("icon")).toBeTruthy(); + expect(screen.getByPlaceholderText("Paste a link...")).toBeTruthy(); + }); + + it("renders input with button", () => { + render( + + + + {}}> + + + + , + ); + expect( + screen.getByRole("button", { name: "Show password" }), + ).toBeTruthy(); + }); + + it("renders input with suffix", () => { + render( + + + .workers.dev + , + ); + expect(screen.getByRole("textbox")).toBeTruthy(); + expect(screen.getByText(".workers.dev")).toBeTruthy(); + }); + + it("renders all sub-components together", () => { + render( + + /api/ + + .json + , + ); + expect(screen.getByText("/api/")).toBeTruthy(); + expect(screen.getByPlaceholderText("endpoint")).toBeTruthy(); + expect(screen.getByText(".json")).toBeTruthy(); + }); + }); + + describe("addon positioning", () => { + it("places start addon before input in DOM order", () => { + render( + + @ + + , + ); + const addon = screen.getByText("@"); + 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( + + + @example.com + , + ); + const addon = screen.getByText("@example.com"); + 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( + + + + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Show password" })); + 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: "Delete search" })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("focuses input when clicking on container", async () => { + const user = userEvent.setup(); + const { container } = render( + + @ + + , + ); + + const group = container.firstElementChild as HTMLElement; + await user.click(group); + expect(document.activeElement).toBe(screen.getByRole("textbox")); + }); + + it("focuses input when clicking on div container (label prop)", async () => { + const user = userEvent.setup(); + const { container } = render( + + + .workers.dev + , + ); + + // The invisible label overlay inside the container delegates focus + // to the input via native htmlFor. We click it directly because + // happy-dom doesn't simulate CSS pointer-events-none on the suffix. + const overlay = container.querySelector( + "label[aria-hidden='true']", + ) as HTMLElement; + await user.click(overlay); + expect(document.activeElement).toBe(screen.getByRole("textbox")); + }); + + it("does not redirect focus to input when clicking a button", async () => { + const user = userEvent.setup(); + render( + + + + {}}> + + + + , + ); + + const button = screen.getByRole("button", { name: "Show password" }); + await user.click(button); + expect(document.activeElement).toBe(button); + }); + }); + + 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(); + }); + + it("does not allow focus via click when disabled", () => { + const { container } = render( + + + + + + , + ); + + const label = container.querySelector("[data-slot='input-group']"); + expect(label?.getAttribute("data-disabled")).toBe(""); + expect(label?.className).toContain("pointer-events-none"); + }); + }); + + describe("error handling", () => { + it("sets aria-invalid on input when error is present", () => { + render( + + + @example.com + , + ); + + 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( + + + + + + , + ); + + // Just verify it renders without error at different sizes + expect(screen.getByRole("textbox")).toBeTruthy(); + + rerender( + + + + + + , + ); + 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) => { + const labels: Record = { + xs: "Extra Small", + sm: "Small", + base: "Base (default)", + lg: "Large", + }; + render( + + + + + + , + ); + + const addon = screen.getByTestId("icon").closest("[data-slot]")!; + const expectedClass = INPUT_GROUP_SIZE[size].addonOuterStart; + 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", () => { + it("input has accessible name via aria-label", () => { + render( + + @ + + , + ); + expect(screen.getByRole("textbox", { name: "Username" })).toBeTruthy(); + }); + + it("button inside addon remains accessible", () => { + render( + + + + {}}> + + + + , + ); + expect( + screen.getByRole("button", { name: "Show password" }), + ).toBeTruthy(); + }); + + it("container is a