// ~/src/siteplane/components/input-group/input-group.tsx "use client"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { formControlSurfaceClassNames, formControlTextClassNames, surfaceClassNames, type FormControlTextSize, type FormControlVariant, } from "../../system/styles/surface"; import { cn } from "../../system/utils/cn"; import { baseClass, slotClass } from "../../system/utils/slot-class"; import type { InputProps } from "../input"; import { KitInput } from "../input/input-internal"; import { type TextareaProps } from "../textarea"; import { KitTextarea } from "../textarea/textarea-internal"; import "./input-group.css"; const inputGroupInteractiveSelector = "button, a, input, select, textarea, [contenteditable='true'], [role='button'], [role='combobox'], [role='listbox'], [role='spinbutton'], [data-slot='select-trigger']"; // An addon's cursor, line height, its order and padding by alignment, and the // first-line alignment beside a textarea are the kit base (design-rework.css). // The addon spaces the icons it holds; design-rework.css sizes them, but not // the icon of a Button in it, which the Button's own icon rule sizes. const inputGroupAddonVariants = cva( "flex h-auto select-none justify-center gap-2 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80", { defaultVariants: { align: "inline-start", }, variants: { align: { "block-end": "w-full justify-start", "block-start": "w-full justify-start", "inline-end": "", "inline-start": "", }, }, }, ); /** Whether the InputGroup opens with an addon (see `opensWithAddon`). */ const InputGroupInsetStartContext = React.createContext(false); /** Whether the InputGroup keeps the end of its control clear (`insetEnd`). */ const InputGroupInsetEndContext = React.createContext(false); /** * Whether an InputGroupAddon at the inline start, its default alignment, is * among the group's children, also inside a Fragment: an icon or a text * prefix before the input. Read from the children while rendering, so the * server markup is already right. */ function opensWithAddon(children: React.ReactNode): boolean { return React.Children.toArray(children).some((child) => { if ( !React.isValidElement<{ align?: unknown; children?: React.ReactNode }>( child, ) ) return false; if (child.type === React.Fragment) return opensWithAddon(child.props.children); return ( child.type === InputGroupAddon && (child.props.align ?? "inline-start") === "inline-start" ); }); } /** * A group that opens with an addon tells its input, whose start edge then * adjoins the addon (Input `insetStart`). In a floating Field that input keeps * the label above the group: a floating field takes no leading icon. */ export function InputGroup({ children, className, insetEnd = false, onMouseDown, textSize, variant = "default", ...props }: React.ComponentProps<"div"> & { /** * Keeps the end of the input or textarea clear for an action placed over * it, such as a square Button 8 px inside the top-end corner of a textarea. * The text ends 8 px before a Button of the control size. */ insetEnd?: boolean | undefined; textSize?: FormControlTextSize | undefined; variant?: FormControlVariant | undefined; }): React.ReactElement { // The room follows the control size, which a Button in the group takes too. const controlSize = useResolvedControlSize(undefined); return (
{ onMouseDown?.(event); if ( event.defaultPrevented || (event.target as HTMLElement).closest(inputGroupInteractiveSelector) ) { return; } const controls = Array.from( event.currentTarget.querySelectorAll( "input:not([hidden]):not([type='hidden']):not(:disabled), textarea:not(:disabled), [contenteditable='true'], [role='spinbutton']:not([aria-disabled='true'])", ), ); const control = controls.find( (candidate) => event.clientX <= candidate.getBoundingClientRect().right, ) ?? controls.at(-1); if (!control) return; event.preventDefault(); control.focus(); if ( control instanceof HTMLInputElement && control.closest(".sp-input-segment") ) { control.select(); } }} role="group" {...props} > {children}
); } export function InputGroupAddon({ className, align = "inline-start", divider = false, padding = "default", ...props }: React.ComponentProps<"div"> & VariantProps & { divider?: boolean; padding?: "default" | "compact" | "none"; }): React.ReactElement { return (
); } export function InputGroupText({ className, ...props }: React.ComponentProps<"span">): React.ReactElement { // The text sits on a 1em line, and the kit font draws descenders below it, // so a long text is clipped at the sides only. design-rework.css sizes its // icons beside a field control. return ( ); } export function InputGroupInput({ className, insetStart, ...props }: Omit): React.ReactElement { // An addon before the input takes its start edge. const addonBefore = React.useContext(InputGroupInsetStartContext); return ( ); } export function InputGroupSegment({ className, insetStart = false, onFocus, width = "2-digit", ...props }: Omit & { insetStart?: boolean; width?: "2-digit" | "4-digit" | "fill"; }): React.ReactElement { return ( { event.currentTarget.select(); onFocus?.(event); }} profile="input-group" unstyled /> ); } export function InputGroupTextarea({ className, ...props }: Omit): React.ReactElement { // A group with insetEnd keeps the end of the textarea clear. const insetEnd = React.useContext(InputGroupInsetEndContext); return ( ); } // ~/src/siteplane/components/input-group/input-group.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The field scope. Every InputGroup carries data-variant; the rules below * name it, also beside a variant value, so their specificity stays in step * with each other. */ .sp-base-input-group[data-variant] { --input-group-surface-subtle: rgb(0 0 0 / 4%); --input-segment-background: color-mix( in srgb, var(--color-blue-500) 16%, transparent ); --input-segment-padding-block: calc(var(--spacing) * 0.5); --input-segment-padding-inline: var(--spacing); --sp-semantic-color-background: var(--color-white); --sp-semantic-color-border: rgb(0 0 0 / 8%); --sp-semantic-color-destructive: var(--color-red-500); --sp-semantic-color-foreground: var(--color-neutral-800); --sp-semantic-color-input: rgb(0 0 0 / 10%); --sp-semantic-color-muted-foreground: color-mix( in srgb, var(--color-neutral-500) 90%, var(--color-black) ); --sp-semantic-color-ring: var(--color-neutral-400); } /* InputGroup exposes only composite context. Public child profiles decide how * these signals map onto their own internal control anatomy. */ .sp-base-input-group { --sp-composite-control-height: initial; --sp-composite-control-padding-block-end: initial; --sp-composite-control-padding-block-start: initial; --sp-composite-control-padding-inline-end: initial; --sp-composite-control-padding-inline-start: initial; } .sp-base-input-group:has(> .sp-input-group-addon[data-align="inline-start"]) { --sp-composite-control-padding-inline-start: calc(var(--spacing) * 1.5); } .sp-base-input-group:has(> .sp-input-group-addon[data-align="inline-end"]) { --sp-composite-control-padding-inline-end: calc(var(--spacing) * 1.5); } .sp-base-input-group:has( > .sp-input-group-addon[data-align="block-start"], > .sp-input-group-addon[data-align="block-end"] ) { --sp-composite-control-height: auto; } .sp-base-input-group:has(> .sp-input-group-addon[data-align="block-start"]) { --sp-composite-control-padding-block-end: calc(var(--spacing) * 1.5); } .sp-base-input-group:has(> .sp-input-group-addon[data-align="block-end"]) { --sp-composite-control-padding-block-start: calc(var(--spacing) * 1.5); } /* insetEnd: the end of the control stays clear for an action placed over * it, such as a square Button 8 px inside the top-end corner of a textarea. * The text ends 8 px before that Button, which takes the control size the * attribute names. The end signal carries the room to the Input and * Textarea profiles. */ .sp-base-input-group[data-inset-end] { --input-group-inset-end: calc( var(--sp-rw-control-lg) + var(--sp-rw-space-4) ); --sp-composite-control-padding-inline-end: var(--input-group-inset-end); } .sp-base-input-group[data-inset-end="sm"] { --input-group-inset-end: calc( var(--sp-rw-control-sm) + var(--sp-rw-space-4) ); } .sp-base-input-group[data-inset-end="md"] { --input-group-inset-end: calc( var(--sp-rw-control-md) + var(--sp-rw-space-4) ); } .sp-base-input-group[data-inset-end="xl"] { --input-group-inset-end: calc( var(--sp-rw-control-xl) + var(--sp-rw-space-4) ); } .dark .sp-base-input-group[data-variant], [data-theme="dark"] .sp-base-input-group[data-variant] { --input-group-surface-subtle: rgb(255 255 255 / 4%); --sp-semantic-color-background: color-mix( in srgb, var(--color-neutral-950) 95%, var(--color-white) ); --sp-semantic-color-border: rgb(255 255 255 / 6%); --sp-semantic-color-destructive: color-mix( in srgb, var(--color-red-500) 90%, var(--color-white) ); --sp-semantic-color-foreground: var(--color-neutral-100); --sp-semantic-color-input: rgb(255 255 255 / 8%); --sp-semantic-color-muted-foreground: color-mix( in srgb, var(--color-neutral-500) 90%, var(--color-white) ); --sp-semantic-color-ring: var(--color-neutral-500); } .sp-base-input-group[data-variant][data-variant="soft"] { background-color: var(--input-group-surface-subtle); } .sp-base-input-group[data-variant][data-variant="soft"]:focus-within { background-color: var(--sp-semantic-color-background); } /* Disabled with its own control, not with a disabled action inside it * (design-rework.css, the field disabled rule). */ .sp-base-input-group[data-variant]:has( :is(input:not([type="hidden"]), textarea):disabled, [role="spinbutton"][aria-disabled="true"] ) { background-color: var(--input-group-surface-subtle); } .sp-base-input-group[data-variant]:has(:autofill) { background-color: color-mix( in oklab, var(--sp-semantic-color-foreground) 4%, transparent ); } :is(.dark, [data-theme="dark"]) .sp-base-input-group[data-variant]:has(:autofill) { background-color: color-mix( in oklab, var(--sp-semantic-color-foreground) 8%, transparent ); } .sp-base-input-group:has(.sp-input-segment, .sp-base-date-field) { cursor: text; } .sp-input-segment > input { background-clip: content-box; border-radius: calc( var(--radius-md) + var(--input-segment-padding-block) ) !important; caret-color: var(--color-primary); cursor: text; padding-block: var(--input-segment-padding-block) !important; padding-inline: var(--input-segment-padding-inline) !important; text-align: center; } .sp-input-segment > input:focus { background-color: var(--input-segment-background); } .sp-input-segment > input[data-segment-width="2-digit"] { flex: none !important; width: calc(var(--spacing) * 8) !important; } .sp-input-segment > input[data-segment-width="4-digit"] { flex: none !important; width: calc(var(--spacing) * 12) !important; } .sp-input-segment > input[data-segment-width="fill"] { flex: 1 1 0% !important; width: auto !important; } .sp-input-segment > input[data-segment-inset-start="true"] { margin-inline-start: calc(var(--spacing) * 2); } .sp-input-segment > input::selection { background-color: transparent; } } // ~/src/siteplane/components/input-group/index.ts export * from "./input-group";