Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deep-glasses-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shipfox/react-ui": minor
---

Add Combobox and ScrollArea
135 changes: 135 additions & 0 deletions libs/react/ui/src/components/combobox/combobox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type {Meta, StoryObj} from '@storybook/react';
import {Label} from 'components/label';
import {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'},
{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 = {
title: 'Components/Combobox',
component: Combobox,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Combobox>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {} as never,
render: () => {
const [value, setValue] = useState('');

return (
<div className="w-[80vw] md:w-500">
<Label htmlFor="combobox-default">Search repositories</Label>
<Combobox
id="combobox-default"
options={sampleItems}
value={value}
onValueChange={setValue}
placeholder="Type to search..."
searchPlaceholder="Search repositories..."
emptyState="No repository found."
/>
</div>
);
},
};

export const EmptyState: Story = {
args: {} as never,
render: () => {
const [value, setValue] = useState('abcxyz');

return (
<div className="w-[80vw] md:w-500">
<Label htmlFor="combobox-empty">No results</Label>
<Combobox
id="combobox-empty"
options={[]}
value={value}
onValueChange={setValue}
placeholder="Type to search..."
searchPlaceholder="Search repositories..."
emptyState={
<p className="px-4 whitespace-pre-wrap">
Repository list is limited to 100.{' '}
<a
href="https://support.example.com"
target="_blank"
rel="noopener noreferrer"
className="underline text-foreground-neutral-base"
>
Contact us
</a>{' '}
for support.
</p>
}
/>
</div>
);
},
};

export const LoadingState: Story = {
args: {} as never,
render: () => {
const [value, setValue] = useState('');
return (
<div className="w-[80vw] md:w-500">
<Label htmlFor="combobox-loading">Loading</Label>
<Combobox
id="combobox-loading"
options={sampleItems}
value={value}
onValueChange={setValue}
placeholder="Type to search..."
searchPlaceholder="Search repositories..."
isLoading
/>
</div>
);
},
};

export const DisabledState: Story = {
args: {} as never,
render: () => {
const [value, setValue] = useState('apache');

return (
<div className="w-[80vw] md:w-500">
<Label htmlFor="combobox-disabled">Disabled</Label>
<Combobox
id="combobox-disabled"
options={sampleItems}
value={value}
onValueChange={setValue}
disabled
placeholder="Disabled input"
searchPlaceholder="Search repositories..."
emptyState="No results found"
/>
</div>
);
},
};
124 changes: 124 additions & 0 deletions libs/react/ui/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
@@ -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<CommandTriggerProps, 'children' | 'placeholder'> & {
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<CommandTrigger
variant={variant}
size={size}
placeholder={placeholder}
className={className}
isLoading={isLoading}
{...triggerProps}
>
{selectedOption?.label}
</CommandTrigger>
</PopoverTrigger>
<PopoverContent
className={cn('w-(--radix-popover-trigger-width) p-0', popoverClassName)}
align={align}
sideOffset={sideOffset}
onWheel={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<ScrollArea>
<CommandList className="max-h-300">
<CommandEmpty>{emptyState}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(selectedValue) => {
handleValueChange(currentValue === selectedValue ? '' : selectedValue);
setOpen(false);
}}
>
<Icon
name="check"
className={cn(
'size-16 mr-8',
currentValue === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
}
1 change: 1 addition & 0 deletions libs/react/ui/src/components/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './combobox';
22 changes: 18 additions & 4 deletions libs/react/ui/src/components/command/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ const commandTriggerVariants = cva(
type CommandTriggerProps = ComponentProps<'button'> &
VariantProps<typeof commandTriggerVariants> & {
placeholder?: string;
isLoading?: boolean;
};

const CommandTrigger = forwardRef<HTMLButtonElement, CommandTriggerProps>(
({className, variant, size, placeholder, children, ...props}, ref) => {
({className, variant, size, placeholder, children, isLoading, ...props}, ref) => {
const hasValue = Boolean(children);

return (
Expand All @@ -56,7 +57,14 @@ const CommandTrigger = forwardRef<HTMLButtonElement, CommandTriggerProps>(
{...props}
>
<span className="flex-1 text-left truncate">{hasValue ? children : placeholder}</span>
<Icon name="arrowDownSLine" className="size-16 text-foreground-neutral-muted shrink-0" />
{isLoading ? (
<Icon name="spinner" className="size-16 text-foreground-neutral-base shrink-0" />
) : (
<Icon
name="expandUpDownLine"
className="size-16 text-foreground-neutral-muted shrink-0"
/>
)}
</button>
);
},
Expand Down Expand Up @@ -178,13 +186,19 @@ function CommandList({className, ...props}: ComponentProps<typeof CommandPrimiti
);
}

function CommandEmpty({className, ...props}: ComponentProps<typeof CommandPrimitive.Empty>) {
function CommandEmpty({
className,
children,
...props
}: ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn('py-24 text-center text-sm text-foreground-neutral-muted', className)}
{...props}
/>
>
{children}
</CommandPrimitive.Empty>
);
}

Expand Down
2 changes: 2 additions & 0 deletions libs/react/ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
1 change: 1 addition & 0 deletions libs/react/ui/src/components/scroll-area/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './scroll-area';
53 changes: 53 additions & 0 deletions libs/react/ui/src/components/scroll-area/scroll-area.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="h-full w-full rounded-[inherit]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}

function ScrollBar({
className,
orientation = 'vertical',
...props
}: ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-10 border-l border-l-transparent',
orientation === 'horizontal' && 'h-10 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border-neutral-strong relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}

export {ScrollArea, ScrollBar};
Loading