diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 209cb81da04..21fa4b21161 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1611,6 +1611,28 @@ export const Index: Record = { 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", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 459ed848096..3d13330a4cf 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -87,6 +87,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/form-item-layout', items: [], }, + { + title: 'Multi Select', + href: '/docs/fragments/multi-select', + items: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx new file mode 100644 index 00000000000..d1566b198ac --- /dev/null +++ b/apps/design-system/content/docs/fragments/multi-select.mdx @@ -0,0 +1,42 @@ +--- +title: Multi Select +description: Multiple selection component. +component: true +fragment: true +--- + + + +## Usage + +```tsx +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' +``` + +```tsx + + + + + + + Item 1 + Item 2 + Item 3 + + + +``` + +## Examples + +### Form + + diff --git a/apps/design-system/next.config.mjs b/apps/design-system/next.config.mjs index 1d3df144ace..3c67b4ac18e 100644 --- a/apps/design-system/next.config.mjs +++ b/apps/design-system/next.config.mjs @@ -14,6 +14,10 @@ const nextConfig = { protocol: 'https', hostname: 'images.unsplash.com', }, + { + protocol: 'https', + hostname: 'pbs.twimg.com', + }, ], }, async redirects() { diff --git a/apps/design-system/registry/default/example/multi-select-demo.tsx b/apps/design-system/registry/default/example/multi-select-demo.tsx new file mode 100644 index 00000000000..faa684876f7 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-demo.tsx @@ -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([]) + + return ( + + + + + + + Item 1 + Item 2 + Item 3 + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-form.tsx b/apps/design-system/registry/default/example/multi-select-form.tsx new file mode 100644 index 00000000000..339c5ca6b6f --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-form.tsx @@ -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 + +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
({ + 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 ( + + + ( + + Invite people + + + + + + + {users.map((user) => ( + +
+ {user.name} + {user.name} +
+
+ ))} +
+
+
+ + Select people to invite to this event + + +
+ )} + /> + + +
+ ) +} + +export default MultiSelectZod diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 0f5c5728119..a55270130a5 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -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', diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx index 5958378c28d..fa81a9fc59a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx index 4586d246c21..9adc294335b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index 993827d26ca..4ac909504bc 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index c3d13ae124f..a58832c1398 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Support/SupportForm.tsx b/apps/studio/components/interfaces/Support/SupportForm.tsx index fd07f9c0561..a2906ac5cc6 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.tsx @@ -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' diff --git a/packages/ui-patterns/MultiSelect/Badges.tsx b/packages/ui-patterns/MultiSelectDeprecated/Badges.tsx similarity index 91% rename from packages/ui-patterns/MultiSelect/Badges.tsx rename to packages/ui-patterns/MultiSelectDeprecated/Badges.tsx index f5c425d5a67..859111fb56f 100644 --- a/packages/ui-patterns/MultiSelect/Badges.tsx +++ b/packages/ui-patterns/MultiSelectDeprecated/Badges.tsx @@ -1,5 +1,8 @@ import { IconX } from 'ui' +/** + * @deprecated Use ./multi-select instead + */ export const BadgeDisabled = ({ name }: { name: string }) => (
(
) +/** + * @deprecated Use ./multi-select instead + */ export const BadgeSelected = ({ name, handleRemove, diff --git a/packages/ui-patterns/MultiSelect/MultiSelectV2.tsx b/packages/ui-patterns/MultiSelectDeprecated/MultiSelectV2.tsx similarity index 99% rename from packages/ui-patterns/MultiSelect/MultiSelectV2.tsx rename to packages/ui-patterns/MultiSelectDeprecated/MultiSelectV2.tsx index 0974f8388d1..c9c55493307 100644 --- a/packages/ui-patterns/MultiSelect/MultiSelectV2.tsx +++ b/packages/ui-patterns/MultiSelectDeprecated/MultiSelectV2.tsx @@ -33,6 +33,9 @@ interface MultiSelectProps { onChange?(x: string[]): void } +/** + * @deprecated Use ./multi-select instead + */ export const MultiSelectV2 = ({ options, value, diff --git a/packages/ui-patterns/MultiSelect/index.tsx b/packages/ui-patterns/MultiSelectDeprecated/index.tsx similarity index 99% rename from packages/ui-patterns/MultiSelect/index.tsx rename to packages/ui-patterns/MultiSelectDeprecated/index.tsx index e0dae0bbce0..805b87313f1 100644 --- a/packages/ui-patterns/MultiSelect/index.tsx +++ b/packages/ui-patterns/MultiSelectDeprecated/index.tsx @@ -44,7 +44,7 @@ interface Props { */ /** - * @deprecated To be replaced with MultiSelectV2 + * @deprecated Use ./multi-select instead */ export default function MultiSelect({ options, diff --git a/packages/ui-patterns/multi-select/index.tsx b/packages/ui-patterns/multi-select/index.tsx new file mode 100644 index 00000000000..442d6e22594 --- /dev/null +++ b/packages/ui-patterns/multi-select/index.tsx @@ -0,0 +1 @@ +export * from './multi-select' diff --git a/packages/ui-patterns/multi-select/multi-select.tsx b/packages/ui-patterns/multi-select/multi-select.tsx new file mode 100644 index 00000000000..52ae002775d --- /dev/null +++ b/packages/ui-patterns/multi-select/multi-select.tsx @@ -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 & + VariantProps + +interface MultiSelectContextProps { + value: string[] + onValueChange: (value: any) => void + open: boolean + setOpen: (value: boolean) => void + inputValue: string + setInputValue: React.Dispatch> + activeIndex: number + setActiveIndex: React.Dispatch> + size: MultiSelectorProps['size'] +} + +const MultiSelectContext = createContext(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(false) + const [activeIndex, setActiveIndex] = useState(-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) => { + 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 ( + + + {children} + + + ) +} + +export interface ButtonVariantProps extends React.HTMLAttributes {} + +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( + ({ className, children, ...props }, ref) => { + const { value, onValueChange, activeIndex, size } = useMultiSelect() + + const mousePreventDefault = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + return ( +
+ {value.map((item, index) => ( +
+ {item} + +
+ ))} + {children} +
+ ) + } +) + +MultiSelectorTrigger.displayName = 'MultiSelectorTrigger' + +const MultiSelectorInputVariants = cva('bg-control border', { + variants: { + size: { + ...SIZE_VARIANTS, + }, + }, + defaultVariants: { + size: SIZE_VARIANTS_DEFAULT, + }, +}) + +const MultiSelectorInput = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, size } = useMultiSelect() + return ( + 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>( + ({ children }, ref) => { + const { open } = useMultiSelect() + return ( +
+ {open && children} +
+ ) + } +) + +MultiSelectorContent.displayName = 'MultiSelectorContent' + +const MultiSelectorList = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children }, ref) => { + return ( + + {children} + + No results found + + + ) +}) + +MultiSelectorList.displayName = 'MultiSelectorList' + +const MultiSelectorItem = forwardRef< + React.ElementRef, + { value: string } & React.ComponentPropsWithoutRef +>(({ 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 ( + { + 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} + > +
{children}
+ {isIncluded && ( +
+ +
+ )} +
+ ) +}) + +MultiSelectorItem.displayName = 'MultiSelectorItem' + +export { + MultiSelector, + MultiSelectorTrigger, + MultiSelectorInput, + MultiSelectorContent, + MultiSelectorList, + MultiSelectorItem, +} diff --git a/packages/ui/src/lib/constants.ts b/packages/ui/src/lib/constants.ts index 67b00e06dca..6ce03165163 100644 --- a/packages/ui/src/lib/constants.ts +++ b/packages/ui/src/lib/constants.ts @@ -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'