[Design System] feat: Multi Select (#26719)

* chore: deprecate old files. add new one with first story

* init multi select

* docs

* Update multi-select.mdx

* deprecation notices

* Delete MultiSelect.stories.tsx
This commit is contained in:
Jonathan Summers-Muir authored and GitHub committed 2024-05-24 15:54:51 +08:00
1 parent d1037d95ba
commit 6dff3a7369
18 files changed
+646 -7

No files matched your search

+22
View File
@@ -1611,6 +1611,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"multi-select-demo": {
name: "multi-select-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-demo")),
source: "",
files: ["registry/default/example/multi-select-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-form": {
name: "multi-select-form",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-form")),
source: "",
files: ["registry/default/example/multi-select-form.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"color-usage-surface-www-and-docs": {
name: "color-usage-surface-www-and-docs",
type: "docs:example",
+5
View File
@@ -87,6 +87,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/form-item-layout',
items: [],
},
{
title: 'Multi Select',
href: '/docs/fragments/multi-select',
items: [],
},
],
},
{
@@ -0,0 +1,42 @@
---
title: Multi Select
description: Multiple selection component.
component: true
fragment: true
---
<ComponentPreview name="multi-select-demo" description="" peekCode wide />
## Usage
```tsx
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
```
```tsx
<MultiSelector values={value} onValuesChange={setValue}>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select items" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="1">Item 1</MultiSelectorItem>
<MultiSelectorItem value="2">Item 2</MultiSelectorItem>
<MultiSelectorItem value="3">Item 3</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
```
## Examples
### Form
<ComponentPreview name="multi-select-form" />
+4
View File
@@ -14,6 +14,10 @@ const nextConfig = {
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'pbs.twimg.com',
},
],
},
async redirects() {
@@ -0,0 +1,28 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [value, setValue] = useState<string[]>([])
return (
<MultiSelector values={value} onValuesChange={setValue} size="small">
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select items" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="1">Item 1</MultiSelectorItem>
<MultiSelectorItem value="2">Item 2</MultiSelectorItem>
<MultiSelectorItem value="3">Item 3</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
@@ -0,0 +1,109 @@
'use client'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import {
Form_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
useToast,
} from 'ui'
import z from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { toast } from 'ui'
import { Button } from 'ui'
import Image from 'next/image'
const form = z.object({
value: z.array(z.string()).nonempty('Please select at least one person'),
})
type Form = z.infer<typeof form>
const users = [
{
name: 'ThePrimeagen',
picture: 'https://pbs.twimg.com/profile_images/1759330620160049152/2i_wkOoK_400x400.jpg',
},
{
name: 'Shadcn',
picture: 'https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg',
},
{
name: 'Theo',
picture: 'https://pbs.twimg.com/profile_images/1605762947686375425/lsoGWWty_400x400.jpg',
},
]
const MultiSelectZod = () => {
const multiForm = useForm<Form>({
resolver: zodResolver(form),
defaultValues: form.parse({ value: [users[0].name] }),
})
const { toast } = useToast()
const onSubmit = (data: Form) => {
toast({
title: 'Form submitted ',
description: JSON.stringify(data, null, 2),
})
}
return (
<Form_Shadcn_ {...multiForm}>
<form onSubmit={multiForm.handleSubmit(onSubmit)} className="space-y-3 grid gap-3 w-full">
<FormField_Shadcn_
control={multiForm.control}
name="value"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Invite people</FormLabel_Shadcn_>
<MultiSelector onValuesChange={field.onChange} values={field.value} size={'small'}>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select people to invite" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{users.map((user) => (
<MultiSelectorItem key={user.name} value={user.name}>
<div className="flex items-center space-x-2">
<Image
src={user.picture}
alt={user.name}
width={32}
height={32}
className="w-8 h-8 rounded-full"
/>
<span>{user.name}</span>
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
<FormDescription_Shadcn_>
Select people to invite to this event
</FormDescription_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Button type="secondary" size="medium" htmlType="submit">
Submit
</Button>
</form>
</Form_Shadcn_>
)
}
export default MultiSelectZod
+10
View File
@@ -943,6 +943,16 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/form-item-layout-with-switch.tsx'],
},
{
name: 'multi-select-demo',
type: 'components:example',
files: ['example/multi-select-demo.tsx'],
},
{
name: 'multi-select-form',
type: 'components:example',
files: ['example/multi-select-form.tsx'],
},
{
name: 'color-usage-surface-www-and-docs',
type: 'docs:example',
@@ -30,7 +30,7 @@ import {
SelectTrigger_Shadcn_,
Select_Shadcn_,
} from 'ui'
import { MultiSelectV2 } from 'ui-patterns/MultiSelect/MultiSelectV2'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
interface PolicyDetailsV2Props {
schema: string
@@ -2,7 +2,7 @@ import { SYSTEM_ROLES } from 'components/interfaces/Database/Roles/Roles.constan
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import MultiSelect from 'ui-patterns/MultiSelect'
import MultiSelect from 'ui-patterns/MultiSelectDeprecated'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { sortBy } from 'lodash'
@@ -32,8 +32,8 @@ import {
SidePanel,
cn,
} from 'ui'
import { MultiSelectOption } from 'ui-patterns/MultiSelect'
import { MultiSelectV2 } from 'ui-patterns/MultiSelect/MultiSelectV2'
import { MultiSelectOption } from 'ui-patterns/MultiSelectDeprecated'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { INDEX_TYPES } from './Indexes.constants'
@@ -26,7 +26,7 @@ import {
Input,
InputNumber,
} from 'ui'
import { MultiSelectV2 } from 'ui-patterns/MultiSelect/MultiSelectV2'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
@@ -29,7 +29,7 @@ import {
Listbox,
Separator,
} from 'ui'
import MultiSelect from 'ui-patterns/MultiSelect'
import MultiSelect from 'ui-patterns/MultiSelectDeprecated'
import DisabledStateForFreeTier from './DisabledStateForFreeTier'
import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.constants'
import { formatMessage, uploadAttachments } from './SupportForm.utils'
@@ -1,5 +1,8 @@
import { IconX } from 'ui'
/**
* @deprecated Use ./multi-select instead
*/
export const BadgeDisabled = ({ name }: { name: string }) => (
<div
className={[
@@ -12,6 +15,9 @@ export const BadgeDisabled = ({ name }: { name: string }) => (
</div>
)
/**
* @deprecated Use ./multi-select instead
*/
export const BadgeSelected = ({
name,
handleRemove,
@@ -33,6 +33,9 @@ interface MultiSelectProps {
onChange?(x: string[]): void
}
/**
* @deprecated Use ./multi-select instead
*/
export const MultiSelectV2 = ({
options,
value,
@@ -44,7 +44,7 @@ interface Props {
*/
/**
* @deprecated To be replaced with MultiSelectV2
* @deprecated Use ./multi-select instead
*/
export default function MultiSelect({
options,
@@ -0,0 +1 @@
export * from './multi-select'
@@ -0,0 +1,375 @@
'use client'
import { Badge } from 'ui'
import {
Command,
CommandItem,
CommandEmpty,
CommandList,
} from 'ui/src/components/shadcn/ui/command'
import { cn } from 'ui/src/lib/utils/cn'
import { Command as CommandPrimitive } from 'cmdk'
import { X as RemoveIcon, Check } from 'lucide-react'
import React, {
KeyboardEvent,
createContext,
forwardRef,
useCallback,
useContext,
useState,
} from 'react'
import { CommandInput } from 'cmdk'
import {
SIZE,
SIZE_VARIANTS,
SIZE_VARIANTS_DEFAULT,
SIZE_VARIANTS_INNER,
} from 'ui/src/lib/constants'
import { VariantProps, cva } from 'class-variance-authority'
import { sizeVariants } from 'ui/src/lib/commonCva'
const MultiSelectorVariants = cva('', {
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
})
type MultiSelectorProps = {
values: string[]
onValuesChange: (value: string[]) => void
loop?: boolean
} & React.ComponentPropsWithoutRef<typeof Command> &
VariantProps<typeof MultiSelectorVariants>
interface MultiSelectContextProps {
value: string[]
onValueChange: (value: any) => void
open: boolean
setOpen: (value: boolean) => void
inputValue: string
setInputValue: React.Dispatch<React.SetStateAction<string>>
activeIndex: number
setActiveIndex: React.Dispatch<React.SetStateAction<number>>
size: MultiSelectorProps['size']
}
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null)
const useMultiSelect = () => {
const context = useContext(MultiSelectContext)
if (!context) {
throw new Error('useMultiSelect must be used within MultiSelectProvider')
}
return context
}
const MultiSelector = ({
values: value,
onValuesChange: onValueChange,
loop = false,
className,
children,
dir,
size = 'small',
...props
}: MultiSelectorProps) => {
const [inputValue, setInputValue] = useState('')
const [open, setOpen] = useState<boolean>(false)
const [activeIndex, setActiveIndex] = useState<number>(-1)
const onValueChangeHandler = useCallback(
(val: string) => {
if (value.includes(val)) {
onValueChange(value.filter((item) => item !== val))
} else {
onValueChange([...value, val])
}
},
[value]
)
// TODO : change from else if use to switch case statement
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const moveNext = () => {
const nextIndex = activeIndex + 1
setActiveIndex(nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex)
}
const movePrev = () => {
const prevIndex = activeIndex - 1
setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex)
}
if ((e.key === 'Backspace' || e.key === 'Delete') && value.length > 0) {
if (inputValue.length === 0) {
if (activeIndex !== -1 && activeIndex < value.length) {
onValueChange(value.filter((item) => item !== value[activeIndex]))
const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1
setActiveIndex(newIndex)
} else {
onValueChange(value.filter((item) => item !== value[value.length - 1]))
}
}
} else if (e.key === 'Enter') {
setOpen(true)
} else if (e.key === 'Escape') {
if (activeIndex !== -1) {
setActiveIndex(-1)
} else {
setOpen(false)
}
} else if (dir === 'rtl') {
if (e.key === 'ArrowRight') {
movePrev()
} else if (e.key === 'ArrowLeft' && (activeIndex !== -1 || loop)) {
moveNext()
}
} else {
if (e.key === 'ArrowLeft') {
movePrev()
} else if (e.key === 'ArrowRight' && (activeIndex !== -1 || loop)) {
moveNext()
}
}
},
[value, inputValue, activeIndex, loop]
)
return (
<MultiSelectContext.Provider
value={{
value,
onValueChange: onValueChangeHandler,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
size: size || 'small',
}}
>
<CommandPrimitive
onKeyDown={handleKeyDown}
className={cn('overflow-visible bg-transparent flex flex-col', className)}
dir={dir}
{...props}
>
{children}
</CommandPrimitive>
</MultiSelectContext.Provider>
)
}
export interface ButtonVariantProps extends React.HTMLAttributes<HTMLDivElement> {}
const MultiSelectorTriggerBadgeVariants = cva(
`
gap-2
w-content
my-[3px]
bg-overlay dark:bg-surface-300
border border-control
bg-opacity-100
rounded-lg
items-center
flex text-foreground-light
border
`,
{
variants: {
size: {
...SIZE_VARIANTS_INNER,
},
active: {
true: 'ring-2 ring-muted-foreground',
false: '',
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
}
)
const MultiSelectorTrigger = forwardRef<HTMLDivElement, ButtonVariantProps>(
({ className, children, ...props }, ref) => {
const { value, onValueChange, activeIndex, size } = useMultiSelect()
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
return (
<div
ref={ref}
className={cn(
'flex flex-wrap gap-1 border border-control bg-foreground/[.026] rounded-lg px-1',
className
)}
{...props}
>
{value.map((item, index) => (
<div
key={item}
className={cn(
MultiSelectorTriggerBadgeVariants({ size: size, active: activeIndex === index })
)}
>
<span>{item}</span>
<button
aria-label={`Remove ${item} option`}
aria-roledescription="button to remove option"
type="button"
onMouseDown={mousePreventDefault}
onClick={() => onValueChange(item)}
>
<span className="sr-only">Remove {item} option</span>
<RemoveIcon
className="transition-colors h-3 w-3 text-foreground-muted hover:text-destructive"
strokeWidth={3}
/>
</button>
</div>
))}
{children}
</div>
)
}
)
MultiSelectorTrigger.displayName = 'MultiSelectorTrigger'
const MultiSelectorInputVariants = cva('bg-control border', {
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
})
const MultiSelectorInput = forwardRef<
React.ElementRef<typeof CommandInput>,
React.ComponentPropsWithoutRef<typeof CommandInput>
>(({ className, ...props }, ref) => {
const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, size } = useMultiSelect()
return (
<CommandInput
{...props}
ref={ref}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
onClick={() => setActiveIndex(-1)}
className={cn(
MultiSelectorInputVariants({ size }),
'text-sm bg-transparent border-none outline-none placeholder:text-foreground-muted flex-1',
className,
activeIndex !== -1 && 'caret-transparent'
)}
/>
)
})
MultiSelectorInput.displayName = 'MultiSelectorInput'
const MultiSelectorContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children }, ref) => {
const { open } = useMultiSelect()
return (
<div ref={ref} className="relative">
{open && children}
</div>
)
}
)
MultiSelectorContent.displayName = 'MultiSelectorContent'
const MultiSelectorList = forwardRef<
React.ElementRef<typeof CommandList>,
React.ComponentPropsWithoutRef<typeof CommandList>
>(({ className, children }, ref) => {
return (
<CommandList
ref={ref}
className={cn(
'p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-overlay shadow-md z-10 border border-overlay top-1',
className
)}
>
{children}
<CommandEmpty>
<span className="text-foreground-muted">No results found</span>
</CommandEmpty>
</CommandList>
)
})
MultiSelectorList.displayName = 'MultiSelectorList'
const MultiSelectorItem = forwardRef<
React.ElementRef<typeof CommandItem>,
{ value: string } & React.ComponentPropsWithoutRef<typeof CommandItem>
>(({ className, value, children, ...props }, ref) => {
const { value: Options, onValueChange, setInputValue, size } = useMultiSelect()
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const isIncluded = Options.includes(value)
return (
<CommandItem
ref={ref}
{...props}
onSelect={() => {
onValueChange(value)
setInputValue('')
}}
className={cn(
'relative',
'text-foreground-light',
'hover:text-foreground',
'rounded-md cursor-pointer px-2 py-1 transition-colors flex justify-between',
size && SIZE.text[size],
className,
isIncluded && 'cursor-default',
props.disabled && 'opacity-75 cursor-not-allowed'
)}
onMouseDown={mousePreventDefault}
>
<div className={cn(isIncluded && 'opacity-50')}>{children}</div>
{isIncluded && (
<div className="w-5 h-5 bg-foreground left-2 rounded-full flex items-center justify-center">
<Check className="h-3 w-3 text-background" strokeWidth={5} />
</div>
)}
</CommandItem>
)
})
MultiSelectorItem.displayName = 'MultiSelectorItem'
export {
MultiSelector,
MultiSelectorTrigger,
MultiSelectorInput,
MultiSelectorContent,
MultiSelectorList,
MultiSelectorItem,
}
+34
View File
@@ -66,6 +66,32 @@ export const SIZE = {
},
}
// used for internal badges/buttons
// such as Button Group Items or Multi Select items
export const SIZE_INNER = {
text: {
tiny: 'text-xs',
small: 'text-sm leading-4',
medium: 'text-sm',
large: 'text-base',
xlarge: 'text-base',
},
padding: {
tiny: 'px-2.5 py-1',
small: 'px-3 py-2',
medium: 'px-4 py-2',
large: 'px-4 py-2',
xlarge: 'px-6 py-3',
},
height: {
tiny: 'h-[24px]',
small: 'h-[28px]',
medium: 'h-[32px]',
large: 'h-[36px]',
xlarge: 'h-[44px]',
},
}
export const SIZE_VARIANTS = {
tiny: `${SIZE.text['tiny']} ${SIZE.padding['tiny']} ${SIZE.height['tiny']}`,
small: `${SIZE.text['small']} ${SIZE.padding['small']} ${SIZE.height['small']}`,
@@ -74,4 +100,12 @@ export const SIZE_VARIANTS = {
xlarge: `${SIZE.text['xlarge']} ${SIZE.padding['xlarge']} ${SIZE.height['xlarge']}`,
}
export const SIZE_VARIANTS_INNER = {
tiny: `${SIZE.text['tiny']} ${SIZE.padding['tiny']} ${SIZE_INNER.height['tiny']}`,
small: `${SIZE.text['small']} ${SIZE.padding['small']} ${SIZE_INNER.height['small']}`,
medium: `${SIZE.text['medium']} ${SIZE.padding['medium']} ${SIZE_INNER.height['medium']}`,
large: `${SIZE.text['large']} ${SIZE.padding['large']} ${SIZE_INNER.height['large']}`,
xlarge: `${SIZE.text['xlarge']} ${SIZE.padding['xlarge']} ${SIZE_INNER.height['xlarge']}`,
}
export const SIZE_VARIANTS_DEFAULT = 'small'