A design system in a box. hip-ui.tngl.io/docs/introduction
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

autcomplete-input

+526 -39
+183
apps/docs/src/components/autocomplete/index.tsx
··· 1 + "use client"; 2 + 3 + import type { 4 + AutocompleteProps as AriaAutocompleteProps, 5 + ListBoxProps, 6 + ValidationResult, 7 + } from "react-aria-components"; 8 + 9 + import * as stylex from "@stylexjs/stylex"; 10 + import { use, useEffect, useRef, useState } from "react"; 11 + import { Autocomplete as AriaAutocomplete } from "react-aria-components"; 12 + 13 + import type { 14 + InputValidationState, 15 + InputVariant, 16 + Size, 17 + StyleXComponentProps, 18 + } from "../theme/types"; 19 + 20 + import { SizeContext } from "../context"; 21 + import { ListBox } from "../listbox"; 22 + import { TextField } from "../text-field"; 23 + import { spacing } from "../theme/spacing.stylex"; 24 + import { usePopoverStyles } from "../theme/usePopoverStyles"; 25 + 26 + const styles = stylex.create({ 27 + wrapper: { 28 + position: "relative", 29 + }, 30 + popover: { 31 + position: "absolute", 32 + left: 0, 33 + paddingTop: spacing["1"], 34 + top: "100%", 35 + width: "100%", 36 + }, 37 + }); 38 + 39 + /** 40 + * Props for the AutocompleteInput component. 41 + * Combines text input with a dropdown list of suggestions. 42 + */ 43 + export interface AutocompleteInputProps<T extends object> 44 + extends 45 + StyleXComponentProps< 46 + Omit<AriaAutocompleteProps<T>, "children" | "isInvalid"> 47 + >, 48 + Pick<ListBoxProps<T>, "renderEmptyState"> { 49 + /** Label for the text field. */ 50 + label?: React.ReactNode; 51 + /** Description text shown below the input. */ 52 + description?: string; 53 + /** Error message or function returning message from validation. */ 54 + errorMessage?: string | ((validation: ValidationResult) => string); 55 + /** Items to display in the suggestions list. */ 56 + items?: Iterable<T>; 57 + /** Render function or content for each list item. */ 58 + children: React.ReactNode | ((item: T) => React.ReactNode); 59 + /** Size of the input and list items. */ 60 + size?: Size; 61 + /** Visual variant of the input. */ 62 + variant?: InputVariant; 63 + /** Validation state override. */ 64 + validationState?: InputValidationState; 65 + /** Placeholder text when input is empty. */ 66 + placeholder?: string; 67 + /** Content to render before the input. */ 68 + prefix?: React.ReactNode; 69 + /** Content to render after the input. */ 70 + suffix?: React.ReactNode; 71 + /** Callback when an item is selected. */ 72 + onAction?: (item: string) => void; 73 + } 74 + 75 + export function AutocompleteInput<T extends object>({ 76 + label, 77 + description, 78 + errorMessage, 79 + children, 80 + items, 81 + style, 82 + size: sizeProp, 83 + variant, 84 + validationState, 85 + placeholder, 86 + prefix, 87 + suffix, 88 + onAction, 89 + renderEmptyState, 90 + ...props 91 + }: AutocompleteInputProps<T>) { 92 + const size = sizeProp || use(SizeContext); 93 + const popoverStyles = usePopoverStyles(); 94 + const wrapperRef = useRef<HTMLDivElement>(null); 95 + const [isOpenState, setIsOpenState] = useState(false); 96 + 97 + const firstItem = items ? [...items][0] : undefined; 98 + const isOnlyMatch = 99 + items && 100 + firstItem && 101 + [...items].length === 1 && 102 + "handle" in firstItem && 103 + firstItem.handle === props.inputValue; 104 + const hasItems = items && [...items].length > 0 && !isOnlyMatch; 105 + const isOpen = hasItems && isOpenState; 106 + 107 + // Open popover when suggestions arrive (prop change) 108 + useEffect(() => { 109 + if (hasItems) { 110 + // eslint-disable-next-line react-hooks/set-state-in-effect, @eslint-react/hooks-extra/no-direct-set-state-in-use-effect -- Sync open state when items prop updates 111 + setIsOpenState(true); 112 + } 113 + }, [hasItems]); 114 + 115 + // Handle blur - close if focus moves outside the autocomplete 116 + const handleBlurCapture = (_e: React.FocusEvent) => { 117 + setTimeout(() => { 118 + const activeElement = document.activeElement; 119 + if ( 120 + wrapperRef.current && 121 + activeElement && 122 + !wrapperRef.current.contains(activeElement) 123 + ) { 124 + setIsOpenState(false); 125 + } 126 + }, 0); 127 + }; 128 + 129 + // Handle focus - reopen if there are items 130 + const handleFocusCapture = () => { 131 + if (hasItems) { 132 + setIsOpenState(true); 133 + } 134 + }; 135 + 136 + // Handle item selection - close autocomplete 137 + const handleAction = (key: React.Key) => { 138 + setIsOpenState(false); 139 + onAction?.(String(key)); 140 + }; 141 + 142 + return ( 143 + <SizeContext value={size}> 144 + <AriaAutocomplete {...props} {...stylex.props(style)}> 145 + <div 146 + ref={wrapperRef} 147 + {...stylex.props(styles.wrapper)} 148 + onBlurCapture={handleBlurCapture} 149 + onFocusCapture={handleFocusCapture} 150 + > 151 + <TextField 152 + label={label} 153 + description={description} 154 + errorMessage={errorMessage} 155 + size={size} 156 + variant={variant} 157 + validationState={validationState} 158 + placeholder={placeholder} 159 + prefix={prefix} 160 + suffix={suffix} 161 + /> 162 + 163 + {isOpen && ( 164 + <div {...stylex.props(styles.popover)}> 165 + <div {...stylex.props(popoverStyles.wrapper)}> 166 + <ListBox 167 + items={items} 168 + selectionMode="none" 169 + renderEmptyState={ 170 + renderEmptyState ?? (() => "No results found.") 171 + } 172 + onAction={handleAction} 173 + > 174 + {children} 175 + </ListBox> 176 + </div> 177 + </div> 178 + )} 179 + </div> 180 + </AriaAutocomplete> 181 + </SizeContext> 182 + ); 183 + }
+43 -14
apps/docs/src/components/image-cropper/index.tsx
··· 78 78 export interface ImageCropperRootProps extends StyleXComponentProps< 79 79 Omit< 80 80 ComponentProps<typeof OriginCropper.Root>, 81 - "className" | "style" | "children" | "onCropChange" | "image" 81 + | "className" 82 + | "style" 83 + | "children" 84 + | "onCropChange" 85 + | "image" 86 + | "aspectRatio" 82 87 > 83 88 > { 84 89 /** ··· 87 92 image: string | Blob; 88 93 /** 89 94 * The desired width/height aspect ratio (e.g., 1, 1.5, 4/3, 16/9). 95 + * Set to `null` or `undefined` for free-form cropping (no aspect ratio constraint). 90 96 * @default 1 91 97 */ 92 - aspectRatio?: number; 98 + aspectRatio?: number | null; 93 99 /** 94 100 * Minimum padding (in pixels) between the crop area edges and the container edges. 95 101 * @default 25 ··· 154 160 export function ImageCropperRoot({ 155 161 style, 156 162 image, 157 - aspectRatio = 1, 163 + aspectRatio, 158 164 cropPadding = 25, 159 165 minZoom = 1, 160 166 maxZoom = 3, ··· 177 183 const newUrl = 178 184 typeof image === "string" ? image : URL.createObjectURL(image); 179 185 180 - // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect 181 186 setImageUrl(newUrl); 182 187 183 188 return () => { ··· 192 197 return createImageUrl(); 193 198 }, [image]); 194 199 200 + // Prepare props for OriginCropper - only include aspectRatio if it's not null/undefined 201 + const cropperProps = { 202 + ...props, 203 + image: imageUrl, 204 + cropPadding, 205 + minZoom, 206 + maxZoom, 207 + zoomSensitivity, 208 + keyboardStep, 209 + zoom, 210 + }; 211 + 212 + // Only add aspectRatio if it's a number (not null/undefined) 213 + if (aspectRatio !== null && aspectRatio !== undefined) { 214 + ( 215 + cropperProps as typeof cropperProps & { aspectRatio: number } 216 + ).aspectRatio = aspectRatio; 217 + } 218 + 219 + const stylexProps = stylex.props(styles.root, style); 220 + // Extract className and any other props, but exclude style if present to avoid CSSStyleDeclaration conflicts 221 + const { 222 + className, 223 + style: _, 224 + ...restStylexProps 225 + } = stylexProps as { 226 + className?: string; 227 + style?: unknown; 228 + [key: string]: unknown; 229 + }; 230 + 195 231 return ( 196 232 <OriginCropper.Root 197 - {...props} 198 - image={imageUrl} 199 - aspectRatio={aspectRatio} 200 - cropPadding={cropPadding} 201 - minZoom={minZoom} 202 - maxZoom={maxZoom} 203 - zoomSensitivity={zoomSensitivity} 204 - keyboardStep={keyboardStep} 205 - zoom={zoom} 233 + {...cropperProps} 234 + className={className} 235 + {...restStylexProps} 206 236 onCropChange={(crop) => { 207 237 if (!crop) { 208 238 onCropChange?.(null); ··· 252 282 img.src = imageUrl; 253 283 }} 254 284 onZoomChange={onZoomChange} 255 - {...stylex.props(styles.root, style)} 256 285 > 257 286 {children} 258 287 </OriginCropper.Root>
+14 -14
apps/docs/src/components/lightbox/index.tsx
··· 79 79 minHeight: 0, 80 80 }, 81 81 imageWrapper: { 82 + overflow: "hidden", 82 83 alignItems: "center", 83 84 display: "flex", 84 85 flexGrow: 1, 85 86 justifyContent: "center", 86 - overflow: "hidden", 87 + position: "relative", 87 88 maxHeight: "100%", 88 - position: "relative", 89 89 maxWidth: "100%", 90 90 minWidth: 0, 91 91 }, ··· 175 175 176 176 const runAnimation = () => { 177 177 const viewportWidth = 178 - globalThis.visualViewport?.width ?? globalThis.innerWidth ?? 800; 178 + globalThis.visualViewport?.width ?? globalThis.innerWidth; 179 179 const wrapperWidth = transitionSize 180 180 ? transitionSize.width 181 181 : wrapper.offsetWidth || wrapper.getBoundingClientRect().width; 182 - const slideDistance = Math.max( 183 - viewportWidth, 184 - wrapperWidth ?? viewportWidth, 185 - ); 182 + const slideDistance = Math.max(viewportWidth, wrapperWidth); 186 183 187 184 const isNext = direction === "next"; 188 185 const outKeyframes = isNext 189 186 ? [ 190 187 { transform: "translateX(0)" }, 191 - { transform: `translateX(-${slideDistance}px)` }, 188 + { transform: `translateX(-${String(slideDistance)}px)` }, 192 189 ] 193 190 : [ 194 191 { transform: "translateX(0)" }, 195 - { transform: `translateX(${slideDistance}px)` }, 192 + { transform: `translateX(${String(slideDistance)}px)` }, 196 193 ]; 197 194 const inKeyframes = isNext 198 195 ? [ 199 - { transform: `translateX(${slideDistance}px)` }, 196 + { transform: `translateX(${String(slideDistance)}px)` }, 200 197 { transform: "translateX(0)" }, 201 198 ] 202 199 : [ 203 - { transform: `translateX(-${slideDistance}px)` }, 200 + { transform: `translateX(-${String(slideDistance)}px)` }, 204 201 { transform: "translateX(0)" }, 205 202 ]; 206 203 ··· 218 215 .finished.then(() => { 219 216 setPreviousIndex(null); 220 217 setTransitionSize(null); 218 + }) 219 + .catch((error: unknown) => { 220 + console.error(error); 221 221 }); 222 222 }; 223 223 ··· 339 339 {previousIndex === null ? ( 340 340 <img 341 341 src={currentImage} 342 - alt={`${alt} ${hasMultiple ? `${String(currentIndex + 1)} of ${String(images.length)}` : ""}`} 342 + alt={`${alt} ${String(currentIndex + 1)}`} 343 343 {...stylex.props(styles.image)} 344 344 /> 345 345 ) : ( ··· 367 367 > 368 368 <img 369 369 src={currentImage} 370 - alt={`${alt} ${hasMultiple ? `${String(currentIndex + 1)} of ${String(images.length)}` : ""}`} 370 + alt={`${alt} ${String(currentIndex + 1)}`} 371 371 {...stylex.props(styles.image)} 372 372 /> 373 373 </div> ··· 401 401 > 402 402 <img 403 403 src={currentImage} 404 - alt={`${alt}`} 404 + alt={alt} 405 405 {...stylex.props(styles.image)} 406 406 /> 407 407 </div>
+3 -4
apps/docs/src/components/star-rating/index.tsx
··· 5 5 import { useCallback, useRef, useState } from "react"; 6 6 import { mergeProps, useKeyboard, usePress } from "react-aria"; 7 7 8 + import type { FlexProps } from "../flex"; 8 9 import type { StyleXComponentProps } from "../theme/types"; 9 10 10 11 import { Flex } from "../flex"; ··· 146 147 } 147 148 148 149 export interface StarRatingInputProps extends StyleXComponentProps< 149 - Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> 150 + Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "style"> 150 151 > { 151 152 /** Current value (1–5). Use with onChange for controlled mode. */ 152 153 value?: number; ··· 172 173 onChange, 173 174 isDisabled = false, 174 175 size = 16, 175 - style, 176 176 "aria-label": ariaLabel = "Rating", 177 177 ...props 178 178 }: StarRatingInputProps) { ··· 240 240 aria-valuenow={value} 241 241 aria-disabled={isDisabled} 242 242 tabIndex={isDisabled ? undefined : 0} 243 - style={style as stylex.StyleXStyles} 244 - {...mergeProps(keyboardProps, props)} 243 + {...mergeProps(keyboardProps, props as FlexProps)} 245 244 > 246 245 <div 247 246 ref={starsRef}
+1
apps/docs/src/components/table/index.tsx
··· 62 62 backgroundColor: uiColor.component1, 63 63 display: "flex", 64 64 justifyContent: "space-between", 65 + height: spacing["10"], 65 66 paddingLeft: { 66 67 default: spacing["2"], 67 68 ":is(:first-child > *)": spacing["2"],
+1 -4
apps/docs/src/components/toast/index.tsx
··· 1 - /* eslint-disable react-refresh/only-export-components -- barrel file re-exports components and utilities */ 2 - export { toasts, type ToastContentType } from "./queue"; 3 - export { type ToastRegionProps, ToastRegion } from "./Toast"; 4 - /* eslint-enable react-refresh/only-export-components */ 1 + export { ToastRegion, type ToastRegionProps } from "./Toast";
+3 -1
apps/docs/src/components/typography/index.tsx
··· 10 10 } from "react"; 11 11 import { useHover } from "react-aria"; 12 12 13 + import type { FlexProps } from "../flex"; 13 14 import type { StyleXComponentProps, TextVariant } from "../theme/types"; 14 15 15 16 import { CopyToClipboardButton } from "../copy-to-clipboard-button"; ··· 424 425 } 425 426 426 427 const url = 428 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 427 429 globalThis.window === undefined 428 430 ? `#${id}` 429 431 : `${globalThis.location.origin}${globalThis.location.pathname}#${id}`; ··· 435 437 align="center" 436 438 data-heading-link 437 439 data-hovered={isHovered || undefined} 438 - {...hoverProps} 440 + {...(hoverProps as FlexProps)} 439 441 style={style} 440 442 > 441 443 <a href={`#${id}`} {...stylex.props(styles.linkedHeadingLink)}>
+7 -2
apps/docs/src/components/window-splitter/index.tsx
··· 12 12 PanelGroup as BasePanelGroup, 13 13 PanelResizer as BasePanelResizer, 14 14 } from "@window-splitter/react"; 15 + import { useHover } from "react-aria"; 15 16 16 17 import type { StyleXComponentProps } from "../theme/types"; 17 18 ··· 25 26 panelResizer: { 26 27 backgroundColor: { 27 28 default: uiColor.border2, 28 - ":hover:not([data-state='dragging'])": uiColor.border3, 29 + ":is([data-hovered]):not(:is([data-state='dragging']))": uiColor.border3, 29 30 ":is([data-state='dragging'])": primaryColor.border2, 30 31 }, 31 32 cursor: { ··· 37 38 hitArea: { 38 39 display: { 39 40 default: "none", 40 - ":is([data-splitter-type='handle']:hover > *)": "block", 41 + ":is([data-hovered] [data-splitter-type='handle'] > *)": "block", 41 42 }, 42 43 position: "absolute", 43 44 ··· 79 80 style, 80 81 ...props 81 82 }: WindowSplitterPanelResizerProps) { 83 + const { hoverProps, isHovered } = useHover({}); 84 + 82 85 return ( 83 86 <BasePanelResizer 84 87 {...props} 88 + {...(hoverProps as PanelResizerProps)} 89 + data-hovered={isHovered || undefined} 85 90 {...stylex.props(styles.panelResizer, style)} 86 91 size="1px" 87 92 >
+39
apps/docs/src/docs/components/form/autocomplete.mdx
··· 1 + --- 2 + title: AutocompleteInput 3 + description: A text input with a dropdown list of suggestions that filters as you type. 4 + --- 5 + 6 + import { PropDocs } from "../../../lib/PropDocs"; 7 + import { Example } from "../../../lib/Example"; 8 + import { Basic } from "../../../examples/autocomplete/basic"; 9 + 10 + <Example src={Basic} /> 11 + 12 + ## Installation 13 + 14 + Run the following command to add the AutocompleteInput component to your project. 15 + 16 + ```bash 17 + pnpm hip install autocomplete 18 + ``` 19 + 20 + The AutocompleteInput component requires the listbox and text-field components. 21 + 22 + Install them if you have not already. 23 + 24 + ```bash 25 + pnpm hip install autocomplete listbox text-field 26 + ``` 27 + 28 + ## Props 29 + 30 + This component is built using the [React Aria Autocomplete](https://react-spectrum.adobe.com/react-aria/Autocomplete.html). 31 + 32 + <PropDocs components={["AutocompleteInput"]} /> 33 + 34 + ## Related Components 35 + 36 + - [ComboBox](/docs/components/form/combobox) - For selecting from a dropdown with search 37 + - [SearchField](/docs/components/form/search-field) - For search input with clear button 38 + - [TextField](/docs/components/form/text-field) - For general text input 39 + - [Select](/docs/components/form/select) - For dropdown selection without search
+1
apps/docs/src/docs/components/form/combobox.mdx
··· 62 62 ## Related Components 63 63 64 64 - [Select](/docs/components/select) - For dropdown selection without search 65 + - [AutocompleteInput](/docs/components/form/autocomplete) - For text input with suggestions 65 66 - [SearchField](/docs/components/search-field) - For search input with clear button 66 67 - [TextField](/docs/components/text-field) - For general text input 67 68 - [Label](/docs/components/label) - For form labels and descriptions
+1
apps/docs/src/docs/components/form/search-field.mdx
··· 46 46 ## Related Components 47 47 48 48 - [TextField](/docs/components/text-field) - For general text input 49 + - [AutocompleteInput](/docs/components/form/autocomplete) - For text input with suggestions 49 50 - [ComboBox](/docs/components/combobox) - For searchable dropdown selection 50 51 - [CommandMenu](/docs/components/command-menu) - For command palette with search 51 52 - [Label](/docs/components/label) - For form labels and descriptions
+32
apps/docs/src/examples/autocomplete/basic.tsx
··· 1 + "use client"; 2 + 3 + import { AutocompleteInput } from "@/components/autocomplete"; 4 + import { ListBoxItem } from "@/components/listbox"; 5 + import { useFilter } from "react-aria-components"; 6 + 7 + const options = [ 8 + { id: "apple", handle: "Apple" }, 9 + { id: "apricot", handle: "Apricot" }, 10 + { id: "banana", handle: "Banana" }, 11 + { id: "blueberry", handle: "Blueberry" }, 12 + { id: "cherry", handle: "Cherry" }, 13 + { id: "grape", handle: "Grape" }, 14 + { id: "orange", handle: "Orange" }, 15 + { id: "strawberry", handle: "Strawberry" }, 16 + ]; 17 + 18 + export function Basic() { 19 + const { contains } = useFilter({ sensitivity: "base" }); 20 + 21 + return ( 22 + <AutocompleteInput 23 + filter={contains} 24 + label="Fruit" 25 + placeholder="Type to search..." 26 + items={options} 27 + onAction={() => {}} 28 + > 29 + {(item) => <ListBoxItem id={item.id}>{item.handle}</ListBoxItem>} 30 + </AutocompleteInput> 31 + ); 32 + }
+2
packages/hip-ui/src/cli/install.tsx
··· 16 16 import { alertDialogConfig } from "../components/alert-dialog/alert-dialog-config.js"; 17 17 import { alertConfig } from "../components/alert/alert-config.js"; 18 18 import { aspectRatioConfig } from "../components/aspect-ratio/aspect-ratio-config.js"; 19 + import { autocompleteConfig } from "../components/autocomplete/autocomplete-config.js"; 19 20 import { avatarConfig } from "../components/avatar/avatar-config.js"; 20 21 import { badgeConfig } from "../components/badge/badge-config.js"; 21 22 import { breadcrumbsConfig } from "../components/breadcrumbs/breadcrumbs-config.js"; ··· 152 153 gridConfig, 153 154 switchConfig, 154 155 aspectRatioConfig, 156 + autocompleteConfig, 155 157 colorSwatchConfig, 156 158 fileDropZoneConfig, 157 159 footerConfig,
+13
packages/hip-ui/src/components/autocomplete/autocomplete-config.ts
··· 1 + import type { ComponentConfig } from "../../types"; 2 + 3 + export const autocompleteConfig: ComponentConfig = { 4 + name: "autocomplete", 5 + filepath: "./index.tsx", 6 + hipDependencies: [ 7 + "../theme/spacing.stylex.tsx", 8 + "../theme/usePopoverStyles.ts", 9 + ], 10 + dependencies: { 11 + "react-aria-components": "^1.13.0", 12 + }, 13 + };
+183
packages/hip-ui/src/components/autocomplete/index.tsx
··· 1 + "use client"; 2 + 3 + import type { 4 + AutocompleteProps as AriaAutocompleteProps, 5 + ListBoxProps, 6 + ValidationResult, 7 + } from "react-aria-components"; 8 + 9 + import * as stylex from "@stylexjs/stylex"; 10 + import { use, useEffect, useRef, useState } from "react"; 11 + import { Autocomplete as AriaAutocomplete } from "react-aria-components"; 12 + 13 + import type { 14 + InputValidationState, 15 + InputVariant, 16 + Size, 17 + StyleXComponentProps, 18 + } from "../theme/types"; 19 + 20 + import { SizeContext } from "../context"; 21 + import { ListBox } from "../listbox"; 22 + import { TextField } from "../text-field"; 23 + import { spacing } from "../theme/spacing.stylex"; 24 + import { usePopoverStyles } from "../theme/usePopoverStyles"; 25 + 26 + const styles = stylex.create({ 27 + wrapper: { 28 + position: "relative", 29 + }, 30 + popover: { 31 + position: "absolute", 32 + left: 0, 33 + paddingTop: spacing["1"], 34 + top: "100%", 35 + width: "100%", 36 + }, 37 + }); 38 + 39 + /** 40 + * Props for the AutocompleteInput component. 41 + * Combines text input with a dropdown list of suggestions. 42 + */ 43 + export interface AutocompleteInputProps<T extends object> 44 + extends 45 + StyleXComponentProps< 46 + Omit<AriaAutocompleteProps<T>, "children" | "isInvalid"> 47 + >, 48 + Pick<ListBoxProps<T>, "renderEmptyState"> { 49 + /** Label for the text field. */ 50 + label?: React.ReactNode; 51 + /** Description text shown below the input. */ 52 + description?: string; 53 + /** Error message or function returning message from validation. */ 54 + errorMessage?: string | ((validation: ValidationResult) => string); 55 + /** Items to display in the suggestions list. */ 56 + items?: Iterable<T>; 57 + /** Render function or content for each list item. */ 58 + children: React.ReactNode | ((item: T) => React.ReactNode); 59 + /** Size of the input and list items. */ 60 + size?: Size; 61 + /** Visual variant of the input. */ 62 + variant?: InputVariant; 63 + /** Validation state override. */ 64 + validationState?: InputValidationState; 65 + /** Placeholder text when input is empty. */ 66 + placeholder?: string; 67 + /** Content to render before the input. */ 68 + prefix?: React.ReactNode; 69 + /** Content to render after the input. */ 70 + suffix?: React.ReactNode; 71 + /** Callback when an item is selected. */ 72 + onAction?: (item: string) => void; 73 + } 74 + 75 + export function AutocompleteInput<T extends object>({ 76 + label, 77 + description, 78 + errorMessage, 79 + children, 80 + items, 81 + style, 82 + size: sizeProp, 83 + variant, 84 + validationState, 85 + placeholder, 86 + prefix, 87 + suffix, 88 + onAction, 89 + renderEmptyState, 90 + ...props 91 + }: AutocompleteInputProps<T>) { 92 + const size = sizeProp || use(SizeContext); 93 + const popoverStyles = usePopoverStyles(); 94 + const wrapperRef = useRef<HTMLDivElement>(null); 95 + const [isOpenState, setIsOpenState] = useState(false); 96 + 97 + const firstItem = items ? [...items][0] : undefined; 98 + const isOnlyMatch = 99 + items && 100 + firstItem && 101 + [...items].length === 1 && 102 + "handle" in firstItem && 103 + firstItem.handle === props.inputValue; 104 + const hasItems = items && [...items].length > 0 && !isOnlyMatch; 105 + const isOpen = hasItems && isOpenState; 106 + 107 + // Open popover when suggestions arrive (prop change) 108 + useEffect(() => { 109 + if (hasItems) { 110 + // eslint-disable-next-line react-hooks/set-state-in-effect, @eslint-react/hooks-extra/no-direct-set-state-in-use-effect -- Sync open state when items prop updates 111 + setIsOpenState(true); 112 + } 113 + }, [hasItems]); 114 + 115 + // Handle blur - close if focus moves outside the autocomplete 116 + const handleBlurCapture = (_e: React.FocusEvent) => { 117 + setTimeout(() => { 118 + const activeElement = document.activeElement; 119 + if ( 120 + wrapperRef.current && 121 + activeElement && 122 + !wrapperRef.current.contains(activeElement) 123 + ) { 124 + setIsOpenState(false); 125 + } 126 + }, 0); 127 + }; 128 + 129 + // Handle focus - reopen if there are items 130 + const handleFocusCapture = () => { 131 + if (hasItems) { 132 + setIsOpenState(true); 133 + } 134 + }; 135 + 136 + // Handle item selection - close autocomplete 137 + const handleAction = (key: React.Key) => { 138 + setIsOpenState(false); 139 + onAction?.(String(key)); 140 + }; 141 + 142 + return ( 143 + <SizeContext value={size}> 144 + <AriaAutocomplete {...props} {...stylex.props(style)}> 145 + <div 146 + ref={wrapperRef} 147 + {...stylex.props(styles.wrapper)} 148 + onBlurCapture={handleBlurCapture} 149 + onFocusCapture={handleFocusCapture} 150 + > 151 + <TextField 152 + label={label} 153 + description={description} 154 + errorMessage={errorMessage} 155 + size={size} 156 + variant={variant} 157 + validationState={validationState} 158 + placeholder={placeholder} 159 + prefix={prefix} 160 + suffix={suffix} 161 + /> 162 + 163 + {isOpen && ( 164 + <div {...stylex.props(styles.popover)}> 165 + <div {...stylex.props(popoverStyles.wrapper)}> 166 + <ListBox 167 + items={items} 168 + selectionMode="none" 169 + renderEmptyState={ 170 + renderEmptyState ?? (() => "No results found.") 171 + } 172 + onAction={handleAction} 173 + > 174 + {children} 175 + </ListBox> 176 + </div> 177 + </div> 178 + )} 179 + </div> 180 + </AriaAutocomplete> 181 + </SizeContext> 182 + ); 183 + }