diff --git a/.changeset/calm-combs-search.md b/.changeset/calm-combs-search.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-combs-search.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index e1392f4f947..d0b68d9e9fb 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -46,6 +46,7 @@ const docModules: Record> = { banner: dynamic(() => import('../stories/banner.mdx')), button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), + combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), item: dynamic(() => import('../stories/item.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index c1f60a6e2b8..f429147e0c6 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -26,6 +26,11 @@ import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button. import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; +import { + Default as ComboboxDefault, + meta as comboboxMeta, + Scrolling as ComboboxScrolling, +} from '../stories/combobox.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, @@ -266,6 +271,12 @@ const drawerComponentModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault }; +const comboboxModule: StoryModule = { + meta: comboboxMeta, + Default: ComboboxDefault, + Scrolling: ComboboxScrolling, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -556,6 +567,7 @@ export const registry: StoryModule[] = [ bannerModule, buttonModule, cardComponentModule, + comboboxModule, flowComponentModule, inputModule, inputGroupModule, diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx new file mode 100644 index 00000000000..3e71610de51 --- /dev/null +++ b/packages/swingset/src/stories/combobox.mdx @@ -0,0 +1,57 @@ +import * as ComboboxStories from './combobox.stories'; + +# Combobox + +## Example + + + +`value` is the selected option; `inputValue` is the search text. Clearing the input clears the selection. + +## Inline lists + +Use `List` inside an existing popover. Set `inline` and bind the root's open state to that popover +so closing resets the search, not the selection. + +```tsx + + + + + + + + country.name}> + {country => ( + + {country.name} + + )} + + + +``` + +## Scrolling + + + +## Parts + +| Part | Description | +| --------------------- | --------------------------------------------------------------------------- | +| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | +| `Combobox.Input` | Search input; renders Mosaic `Input` unless composed through another input. | +| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | +| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | +| `Combobox.List` | Scrollable inline listbox. | +| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | +| `Combobox.Option` | Selectable option with active, selected, and disabled states. | +| `Combobox.Empty` | Empty result message. | diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx new file mode 100644 index 00000000000..b6717050cb6 --- /dev/null +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Combobox } from '@clerk/ui/mosaic/components/combobox'; +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './combobox.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'wip', + title: 'Combobox', + source: 'packages/ui/src/mosaic/components/combobox/combobox.tsx', +}; + +export function Default() { + const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; + + return ( + + + Fruit + + + + } + > + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} + +export function Scrolling() { + const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); + + return ( + + + Fruit + + + + } + > + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} diff --git a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts new file mode 100644 index 00000000000..31fda1947b4 --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -0,0 +1,98 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + positioner: { + outline: 'none', + }, + popup: { + borderRadius: radiusVars['--cl-radius-lg'], + outline: 'none', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + opacity: { + default: 1, + ':where([data-ending-style], [data-starting-style])': 0, + }, + scale: { + default: 1, + ':where([data-ending-style], [data-starting-style])': 0.96, + }, + transformOrigin: 'var(--cl-transform-origin)', + transitionDuration: { + default: '150ms', + '@media (prefers-reduced-motion: reduce)': '0.01ms', + }, + transitionProperty: 'opacity, scale', + transitionTimingFunction: 'ease-out', + maxHeight: 'var(--cl-available-height)', + minWidth: '12.5rem', + }, + viewport: { + padding: space['1'], + gap: space['0.5'], + display: 'flex', + flexDirection: 'column', + maxHeight: '16rem', + }, + list: { + padding: space['1'], + gap: space['0.5'], + display: 'flex', + flexDirection: 'column', + maxHeight: '16rem', + }, + option: { + borderRadius: radiusVars['--cl-radius-md'], + gap: space['2'], + outline: { + default: 'none', + '@media (forced-colors: active)': { + default: null, + ':is([data-active])': '2px solid CanvasText', + }, + }, + paddingInline: space['2'], + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + }, + }, + cursor: { + default: 'pointer', + ':is([data-disabled])': 'not-allowed', + }, + display: 'flex', + flexShrink: 0, + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + opacity: { + default: 1, + ':is([data-disabled])': 0.5, + }, + height: space['9'], + }, + empty: { + paddingBlock: space['6'], + paddingInline: space['3'], + color: colorVars['--cl-color-neutral-faded'], + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + textAlign: 'center', + }, + indicator: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + marginInlineStart: 'auto', + }, +}); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx new file mode 100644 index 00000000000..e31df084c4a --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -0,0 +1,351 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Button } from '../button'; +import { Field } from '../field'; +import { Icon } from '../icon'; +import { InputGroup } from '../input-group'; +import { Combobox } from './combobox'; + +function FloatingCombobox(props?: { onValueChange?: (value: string | null) => void; anchor?: HTMLElement }) { + return ( + + + + + } + > + + + + + + Apple + + + + Banana + + + + + ); +} + +describe('Mosaic Combobox', () => { + it.each([undefined, 'ghost', 'default'] as const)('supports the %s variant outside a group', variant => { + render( + + + , + ); + expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', variant ?? 'default'); + }); + + it('respects an explicit default variant inside a group', () => { + render( + + + + + , + ); + expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', 'default'); + }); + + it('removes the check and selection when the input is cleared', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + await user.click(screen.getByRole('option', { name: 'Apple' })); + await user.clear(screen.getByRole('combobox')); + expect(onValueChange).toHaveBeenLastCalledWith(null); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'false'); + await user.keyboard('{Escape}'); + expect(screen.getByRole('combobox')).toHaveValue(''); + }); + it('keeps the check on the selection while another option is highlighted', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + await user.click(trigger); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + await user.click(screen.getByRole('option', { name: 'Apple' })); + await user.click(trigger); + await user.hover(screen.getByRole('option', { name: 'Banana' })); + expect(screen.getByTestId('apple-check')).toBeVisible(); + expect(screen.getByTestId('apple-check')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.queryByTestId('banana-check')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'true'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(trigger); + expect(screen.getByTestId('banana-check')).toBeVisible(); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + }); + it('positions the popup against the full input group', async () => { + const user = userEvent.setup(); + render(); + const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); + + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + + await waitFor(() => expect(measureGroup).toHaveBeenCalled()); + expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveFocus(); + }); + + it('opens its options from a composed trigger', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + expect(trigger).toHaveClass('cl-button', 'cl-combobox-trigger'); + expect(trigger).toHaveAttribute('data-size', 'sm'); + expect(trigger).toHaveAttribute('data-variant', 'ghost'); + expect(trigger).toHaveAttribute('data-color', 'neutral'); + + await user.click(trigger); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('data-open', ''); + expect(input).toHaveFocus(); + }); + + it('allows an explicit anchor to override the input group', async () => { + const user = userEvent.setup(); + const anchor = document.createElement('div'); + const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); + render(); + const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); + + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + + await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); + expect(measureGroup).not.toHaveBeenCalled(); + }); + + it('anchors to the input without a group and opens with the keyboard', async () => { + const user = userEvent.setup(); + render( + + + + Apple + + , + ); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + const measureInput = vi.spyOn(input, 'getBoundingClientRect'); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + + await waitFor(() => expect(measureInput).toHaveBeenCalled()); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(input).toHaveFocus(); + }); + + it('renders a field-aware, sized input', () => { + render( + + Fruit + + + + Apple + + + Choose a fruit + , + ); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + expect(input).toHaveClass('cl-input', 'cl-combobox-input'); + expect(input).toHaveAttribute('data-size', 'lg'); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Choose a fruit'); + }); + + it('opens a floating popup when the user types', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); + + const listbox = screen.getByRole('listbox'); + const popup = listbox.querySelector('.cl-combobox-popup'); + const viewport = popup?.querySelector('.cl-combobox-viewport'); + expect(listbox).toHaveClass('cl-combobox-positioner'); + expect(popup).toBeInTheDocument(); + expect(viewport).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Apple' }).closest('.cl-combobox-viewport')).toBe(viewport); + }); + + it('selects an option and restores its label to the input', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'b'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + expect(input).toHaveValue('Banana'); + expect(input).toHaveFocus(); + }); + + it('supports keyboard selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + await user.keyboard('{Enter}'); + + expect(onValueChange).toHaveBeenCalledWith('apple'); + }); + + it('discards search text on dismiss without changing the selected value', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + await user.keyboard('{Enter}'); + await user.type(input, ' not a fruit'); + await user.keyboard('{Escape}'); + + expect(input).toHaveValue('Apple'); + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('apple'); + }); + + it('keeps the selected label while reopening the unfiltered collection', async () => { + const user = userEvent.setup(); + render( + + + + + item} + empty={No matches} + > + {item => ( + + {item} + + )} + + + , + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'Ban'); + expect(screen.getAllByRole('option')).toHaveLength(1); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + expect(input).toHaveValue('Banana'); + expect(screen.getAllByRole('option')).toHaveLength(2); + await user.type(input, 'unknown'); + expect(screen.getByText('No matches')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(input).toHaveValue('Banana'); + }); + + it('styles disabled options and prevents their selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + Apple + + + , + ); + + const option = screen.getByRole('option', { name: 'Apple' }); + expect(option).toHaveClass('cl-combobox-option'); + expect(option).toHaveAttribute('aria-disabled', 'true'); + await user.click(option); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('defaults to the ghost Input variant inside an input group', () => { + render( + + + + + + + + United States + + , + ); + + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveClass('cl-input', 'cl-combobox-input'); + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-size', 'lg'); + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-variant', 'ghost'); + expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list'); + expect(screen.getByRole('option', { name: 'United States' })).toHaveClass('cl-combobox-option'); + }); + + it('renders a reusable empty state', () => { + render(No matches); + + expect(screen.getByText('No matches')).toHaveClass('cl-combobox-empty'); + }); +}); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.tsx b/packages/ui/src/mosaic/components/combobox/combobox.tsx new file mode 100644 index 00000000000..fc275a1ac7e --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -0,0 +1,221 @@ +'use client'; + +import type { ComboboxProps } from '@clerk/headless/combobox'; +import { Combobox as HeadlessCombobox } from '@clerk/headless/combobox'; +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Icon } from '../icon'; +import { Input, type InputVariant } from '../input'; +import { useOptionalInputGroupContext } from '../input-group/input-group.context'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { styles } from './combobox.styles'; + +export type ComboboxRootProps = ComboboxProps; +export type ComboboxSize = 'sm' | 'md' | 'lg'; +export type ComboboxTriggerProps = MosaicComponentProps<'button'>; +export const ComboboxCollection = HeadlessCombobox.Collection; + +const ComboboxAnchorContext = React.createContext<{ + anchor: HTMLElement | null; + setAnchor: React.Dispatch>; +} | null>(null); + +export function ComboboxRoot({ sideOffset = 8, ...props }: ComboboxRootProps) { + const [anchor, setAnchor] = React.useState(null); + const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); + return ( + + + + ); +} + +export const ComboboxTrigger = React.forwardRef(function MosaicComboboxTrigger( + { className, style, ...props }, + ref, +) { + return ( + + ); +}); + +export interface ComboboxInputProps extends Omit, 'size'> { + size?: ComboboxSize; + variant?: InputVariant; +} + +export const ComboboxInput = React.forwardRef(function MosaicComboboxInput( + { size: sizeProp, variant: variantProp, render, className, style, ...rest }, + ref, +) { + const inputGroup = useOptionalInputGroupContext(); + const variant = variantProp ?? (inputGroup ? 'ghost' : 'default'); + const setAnchor = React.useContext(ComboboxAnchorContext)?.setAnchor; + const groupElement = inputGroup?.element; + React.useLayoutEffect(() => { + setAnchor?.(groupElement ?? null); + return () => setAnchor?.(null); + }, [groupElement, setAnchor]); + const size = inputGroup?.size ?? sizeProp ?? 'md'; + + return ( + + ) + } + {...mergeStyleProps(themeProps('combobox-input', { size, variant }), className, style)} + {...rest} + /> + ); +}); + +export interface ComboboxPopupProps extends MosaicComponentProps<'div'> { + /** Overrides positioning against the input group or standalone input. */ + anchor?: React.ComponentPropsWithoutRef['anchor']; + /** Container the combobox portals into. Defaults to `document.body`. */ + portalRoot?: React.ComponentPropsWithoutRef['root']; +} + +/** Floating listbox surface. Portal and positioning are handled internally. */ +export const ComboboxPopup = React.forwardRef(function MosaicComboboxPopup( + { anchor, portalRoot, className, style, children, ...rest }, + ref, +) { + const context = React.useContext(ComboboxAnchorContext); + return ( + + + +
+ {children} +
+
+
+
+ ); +}); + +export type ComboboxListProps = MosaicComponentProps<'div'>; + +/** Scrollable listbox used when the combobox is embedded in another surface. */ +export const ComboboxList = React.forwardRef(function MosaicComboboxList( + { className, style, ...rest }, + ref, +) { + return ( + + ); +}); + +export interface ComboboxOptionProps extends MosaicComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export const ComboboxOption = React.forwardRef(function MosaicComboboxOption( + { className, style, ...rest }, + ref, +) { + return ( + + ); +}); + +export type ComboboxEmptyProps = MosaicComponentProps<'p'>; + +export type ComboboxOptionIndicatorProps = MosaicComponentProps<'span'>; + +export const ComboboxOptionIndicator = React.forwardRef( + function MosaicComboboxOptionIndicator({ className, style, children, ...props }, ref) { + return ( + + {children ?? ( + + )} + + ); + }, +); + +export const ComboboxEmpty = React.forwardRef(function MosaicComboboxEmpty( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps(themeProps('combobox-empty'), stylex.props(reset.base, styles.empty), className, style), + ...rest, + }, + }); +}); + +export const Combobox = { + Root: ComboboxRoot, + Collection: ComboboxCollection, + Input: ComboboxInput, + Trigger: ComboboxTrigger, + Popup: ComboboxPopup, + List: ComboboxList, + Option: ComboboxOption, + OptionIndicator: ComboboxOptionIndicator, + Empty: ComboboxEmpty, +}; diff --git a/packages/ui/src/mosaic/components/combobox/index.ts b/packages/ui/src/mosaic/components/combobox/index.ts new file mode 100644 index 00000000000..ffe2c0b2646 --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/index.ts @@ -0,0 +1,23 @@ +export { + Combobox, + ComboboxCollection, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxOption, + ComboboxOptionIndicator, + ComboboxPopup, + ComboboxRoot, + ComboboxTrigger, +} from './combobox'; +export type { + ComboboxEmptyProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxOptionIndicatorProps, + ComboboxPopupProps, + ComboboxRootProps, + ComboboxSize, + ComboboxTriggerProps, +} from './combobox'; diff --git a/packages/ui/src/mosaic/components/input-group/input-group.context.ts b/packages/ui/src/mosaic/components/input-group/input-group.context.ts index 2861a28f43c..99d78b3326d 100644 --- a/packages/ui/src/mosaic/components/input-group/input-group.context.ts +++ b/packages/ui/src/mosaic/components/input-group/input-group.context.ts @@ -5,6 +5,7 @@ import React from 'react'; export type InputGroupSize = 'sm' | 'md' | 'lg'; interface InputGroupContextValue { + element: HTMLDivElement | null; disabled: boolean; invalid: boolean; inputRef: (node: HTMLInputElement | null) => void; diff --git a/packages/ui/src/mosaic/components/input-group/input-group.tsx b/packages/ui/src/mosaic/components/input-group/input-group.tsx index 707876160a9..44cf873cb50 100644 --- a/packages/ui/src/mosaic/components/input-group/input-group.tsx +++ b/packages/ui/src/mosaic/components/input-group/input-group.tsx @@ -28,15 +28,19 @@ const Root = React.forwardRef(function Mosa const field = useOptionalFieldContext(); const disabled = disabledProp ?? field?.disabled ?? false; const invalid = invalidProp ?? field?.invalid ?? false; + const [groupElement, setGroupElement] = React.useState(null); const inputElementRef = React.useRef(null); const inputRef = React.useCallback((node: HTMLInputElement | null) => { inputElementRef.current = node; }, []); - const context = React.useMemo(() => ({ disabled, invalid, inputRef, size }), [disabled, invalid, inputRef, size]); + const context = React.useMemo( + () => ({ element: groupElement, disabled, invalid, inputRef, size }), + [groupElement, disabled, invalid, inputRef, size], + ); const element = useRender({ defaultTagName: 'div', render, - ref, + ref: [ref, setGroupElement], props: { ...mergeStyleProps( themeProps('input-group', { size, disabled, invalid }), diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b869b0742e6..2943f42f266 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -26,6 +26,16 @@ export type { DrawerTitleProps, DrawerTriggerProps, } from '../components/drawer'; +export { Combobox } from '../components/combobox'; +export type { + ComboboxEmptyProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxPopupProps, + ComboboxRootProps, + ComboboxSize, +} from '../components/combobox'; export { Dialog, createConfirmHandle, useConfirmedClose } from '../components/dialog'; export type { ConfirmHandle, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82eb9aa6e47..b3ecba5aa74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9060,7 +9060,6 @@ packages: eslint@9.31.0: resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*'