From 51642dfe3ebc364813b82507ff08b1a9eca1d48c Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Fri, 9 Jan 2026 20:03:52 +0700 Subject: [PATCH 1/4] feat(ui): add Combobox component and integrate ScrollArea --- .../components/combobox/combobox.stories.tsx | 131 ++++++++++++++++++ .../ui/src/components/combobox/combobox.tsx | 124 +++++++++++++++++ .../react/ui/src/components/combobox/index.ts | 1 + .../ui/src/components/command/command.tsx | 22 ++- libs/react/ui/src/components/index.ts | 2 + .../ui/src/components/scroll-area/index.ts | 1 + .../components/scroll-area/scroll-area.tsx | 53 +++++++ 7 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 libs/react/ui/src/components/combobox/combobox.stories.tsx create mode 100644 libs/react/ui/src/components/combobox/combobox.tsx create mode 100644 libs/react/ui/src/components/combobox/index.ts create mode 100644 libs/react/ui/src/components/scroll-area/index.ts create mode 100644 libs/react/ui/src/components/scroll-area/scroll-area.tsx diff --git a/libs/react/ui/src/components/combobox/combobox.stories.tsx b/libs/react/ui/src/components/combobox/combobox.stories.tsx new file mode 100644 index 00000000..56cd6d16 --- /dev/null +++ b/libs/react/ui/src/components/combobox/combobox.stories.tsx @@ -0,0 +1,131 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Label} from 'components/label'; +import {useMemo, useState} from 'react'; +import {Combobox, type ComboboxOption} from './combobox'; + +const sampleItems: ComboboxOption[] = [ + {value: 'apache', label: 'apache'}, + {value: 'apache-superset', label: 'apache-superset'}, + {value: 'apaleo', label: 'apaleo'}, + {value: 'apollo', label: 'apollo'}, + {value: 'apple', label: 'apple'}, + {value: 'apache-kafka', label: 'apache-kafka'}, +]; + +const meta = { + title: 'Components/Combobox', + component: Combobox, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {} as never, + render: () => { + const [value, setValue] = useState(''); + const items = useMemo(() => { + if (value.length < 1) return sampleItems; + const lowerQuery = value.toLowerCase(); + return sampleItems.filter((item) => item.label.toLowerCase().includes(lowerQuery)); + }, [value]); + + return ( +
+ + +
+ ); + }, +}; + +export const EmptyState: Story = { + args: {} as never, + render: () => { + const [value, setValue] = useState('abcxyz'); + + return ( +
+ + + Repository list is limited to 100.{' '} + + Contact us + {' '} + for support. +

+ } + /> +
+ ); + }, +}; + +export const LoadingState: Story = { + args: {} as never, + render: () => { + const [value, setValue] = useState(''); + return ( +
+ + +
+ ); + }, +}; + +export const DisabledState: Story = { + args: {} as never, + render: () => { + const [value, setValue] = useState('apache'); + + return ( +
+ + +
+ ); + }, +}; diff --git a/libs/react/ui/src/components/combobox/combobox.tsx b/libs/react/ui/src/components/combobox/combobox.tsx new file mode 100644 index 00000000..ce0219b6 --- /dev/null +++ b/libs/react/ui/src/components/combobox/combobox.tsx @@ -0,0 +1,124 @@ +import {ScrollArea} from 'components/scroll-area'; +import * as React from 'react'; +import {cn} from 'utils/cn'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandTrigger, + type CommandTriggerProps, +} from '../command'; +import {Icon} from '../icon'; +import {Popover, PopoverContent, PopoverTrigger} from '../popover'; + +export type ComboboxOption = { + value: string; + label: string; +}; + +export type ComboboxProps = Omit & { + options: ComboboxOption[]; + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + emptyState?: string | React.ReactNode; + searchPlaceholder?: string; + className?: string; + popoverClassName?: string; + align?: 'start' | 'center' | 'end'; + sideOffset?: number; + isLoading?: boolean; +}; + +export function Combobox({ + options, + value, + onValueChange, + placeholder = 'Select option...', + emptyState = 'No option found.', + searchPlaceholder = 'Search...', + className, + popoverClassName, + align = 'start', + sideOffset = 4, + variant, + size, + isLoading, + ...triggerProps +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + const [internalValue, setInternalValue] = React.useState(''); + + const isControlled = value !== undefined; + const currentValue = isControlled ? value : internalValue; + + const handleValueChange = React.useCallback( + (newValue: string) => { + if (!isControlled) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + }, + [isControlled, onValueChange], + ); + + const selectedOption = options.find((option) => option.value === currentValue); + + return ( + + + + {selectedOption?.label} + + + e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + + {emptyState} + + {options.map((option) => ( + { + handleValueChange(currentValue === selectedValue ? '' : selectedValue); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + + ); +} diff --git a/libs/react/ui/src/components/combobox/index.ts b/libs/react/ui/src/components/combobox/index.ts new file mode 100644 index 00000000..36dd8c5b --- /dev/null +++ b/libs/react/ui/src/components/combobox/index.ts @@ -0,0 +1 @@ +export * from './combobox'; diff --git a/libs/react/ui/src/components/command/command.tsx b/libs/react/ui/src/components/command/command.tsx index eb495de5..b0d27da0 100644 --- a/libs/react/ui/src/components/command/command.tsx +++ b/libs/react/ui/src/components/command/command.tsx @@ -36,10 +36,11 @@ const commandTriggerVariants = cva( type CommandTriggerProps = ComponentProps<'button'> & VariantProps & { placeholder?: string; + isLoading?: boolean; }; const CommandTrigger = forwardRef( - ({className, variant, size, placeholder, children, ...props}, ref) => { + ({className, variant, size, placeholder, children, isLoading, ...props}, ref) => { const hasValue = Boolean(children); return ( @@ -56,7 +57,14 @@ const CommandTrigger = forwardRef( {...props} > {hasValue ? children : placeholder} - + {isLoading ? ( + + ) : ( + + )} ); }, @@ -178,13 +186,19 @@ function CommandList({className, ...props}: ComponentProps) { +function CommandEmpty({ + className, + children, + ...props +}: ComponentProps) { return ( + > + {children} + ); } diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index 13679f9e..b13ebe6a 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -7,6 +7,7 @@ export * from './calendar'; export * from './card'; export * from './checkbox'; export * from './code-block'; +export * from './combobox'; export * from './command'; export * from './confetti'; export * from './count-up'; @@ -28,6 +29,7 @@ export * from './label'; export * from './modal'; export * from './moving-border'; export * from './popover'; +export * from './scroll-area'; export * from './search'; export * from './select'; export * from './sheet'; diff --git a/libs/react/ui/src/components/scroll-area/index.ts b/libs/react/ui/src/components/scroll-area/index.ts new file mode 100644 index 00000000..cb8dbd10 --- /dev/null +++ b/libs/react/ui/src/components/scroll-area/index.ts @@ -0,0 +1 @@ +export * from './scroll-area'; diff --git a/libs/react/ui/src/components/scroll-area/scroll-area.tsx b/libs/react/ui/src/components/scroll-area/scroll-area.tsx new file mode 100644 index 00000000..e081e203 --- /dev/null +++ b/libs/react/ui/src/components/scroll-area/scroll-area.tsx @@ -0,0 +1,53 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; + +function ScrollArea({ + className, + children, + ...props +}: ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = 'vertical', + ...props +}: ComponentProps) { + return ( + + + + ); +} + +export {ScrollArea, ScrollBar}; From acbc834c6284996540660c0a58d2f8aaf298de84 Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Fri, 9 Jan 2026 20:04:36 +0700 Subject: [PATCH 2/4] feat(ui): add Combobox component with ScrollArea --- .changeset/deep-glasses-play.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/deep-glasses-play.md diff --git a/.changeset/deep-glasses-play.md b/.changeset/deep-glasses-play.md new file mode 100644 index 00000000..f9cd5bf0 --- /dev/null +++ b/.changeset/deep-glasses-play.md @@ -0,0 +1,5 @@ +--- +"@shipfox/react-ui": minor +--- + +Add Combobox and ScrollArea From 0436e928a820800e1b4491ea2b3398df91639b52 Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Fri, 9 Jan 2026 20:16:51 +0700 Subject: [PATCH 3/4] refactor(ui): simplify Combobox story by removing useMemo and using static options --- .../ui/src/components/combobox/combobox.stories.tsx | 9 ++------- libs/react/ui/src/components/scroll-area/scroll-area.tsx | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/libs/react/ui/src/components/combobox/combobox.stories.tsx b/libs/react/ui/src/components/combobox/combobox.stories.tsx index 56cd6d16..67e1c610 100644 --- a/libs/react/ui/src/components/combobox/combobox.stories.tsx +++ b/libs/react/ui/src/components/combobox/combobox.stories.tsx @@ -1,6 +1,6 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Label} from 'components/label'; -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import {Combobox, type ComboboxOption} from './combobox'; const sampleItems: ComboboxOption[] = [ @@ -28,18 +28,13 @@ export const Default: Story = { args: {} as never, render: () => { const [value, setValue] = useState(''); - const items = useMemo(() => { - if (value.length < 1) return sampleItems; - const lowerQuery = value.toLowerCase(); - return sampleItems.filter((item) => item.label.toLowerCase().includes(lowerQuery)); - }, [value]); return (
{children} From 2ab1ffd7e764613d962ca2d4b76ef611036460e4 Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Fri, 9 Jan 2026 20:53:22 +0700 Subject: [PATCH 4/4] fix(ui): ScrollArea styling --- .../ui/src/components/combobox/combobox.stories.tsx | 9 +++++++++ libs/react/ui/src/components/scroll-area/scroll-area.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/react/ui/src/components/combobox/combobox.stories.tsx b/libs/react/ui/src/components/combobox/combobox.stories.tsx index 67e1c610..d37554ce 100644 --- a/libs/react/ui/src/components/combobox/combobox.stories.tsx +++ b/libs/react/ui/src/components/combobox/combobox.stories.tsx @@ -10,6 +10,15 @@ const sampleItems: ComboboxOption[] = [ {value: 'apollo', label: 'apollo'}, {value: 'apple', label: 'apple'}, {value: 'apache-kafka', label: 'apache-kafka'}, + {value: 'apex', label: 'apex'}, + {value: 'appsmith', label: 'appsmith'}, + {value: 'applitools', label: 'applitools'}, + {value: 'approzium', label: 'approzium'}, + {value: 'apify', label: 'apify'}, + {value: 'apicurio', label: 'apicurio'}, + {value: 'apitable', label: 'apitable'}, + {value: 'apollographql', label: 'apollographql'}, + {value: 'aptos', label: 'aptos'}, ]; const meta = { diff --git a/libs/react/ui/src/components/scroll-area/scroll-area.tsx b/libs/react/ui/src/components/scroll-area/scroll-area.tsx index 744803a2..7876856f 100644 --- a/libs/react/ui/src/components/scroll-area/scroll-area.tsx +++ b/libs/react/ui/src/components/scroll-area/scroll-area.tsx @@ -15,7 +15,7 @@ function ScrollArea({ > {children}