From a4e4747ce35f9bb786700d1888d8b417ae2a14b5 Mon Sep 17 00:00:00 2001 From: Jaleel Bennett Date: Tue, 26 Dec 2023 21:29:45 -0500 Subject: [PATCH] feat: mdx code snippets --- app/code-snippets.ts | 5 - .../{auto-complete.tsx => autocomplete.tsx} | 0 content/snippets/autocomplete.mdx | 65 +++ content/snippets/tag-input-demo.mdx | 128 ++++++ content/snippets/tag-input.mdx | 430 ++++++++++++++++++ content/snippets/tag-list.mdx | 41 ++ content/snippets/tag-popover.mdx | 47 ++ content/snippets/tag.mdx | 150 ++++++ 8 files changed, 861 insertions(+), 5 deletions(-) delete mode 100644 app/code-snippets.ts rename components/tag/{auto-complete.tsx => autocomplete.tsx} (100%) create mode 100644 content/snippets/autocomplete.mdx create mode 100644 content/snippets/tag-input-demo.mdx create mode 100644 content/snippets/tag-input.mdx create mode 100644 content/snippets/tag-list.mdx create mode 100644 content/snippets/tag-popover.mdx create mode 100644 content/snippets/tag.mdx diff --git a/app/code-snippets.ts b/app/code-snippets.ts deleted file mode 100644 index 2110461..0000000 --- a/app/code-snippets.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const tagInputCode = - 'import React from "react";\nimport { Input } from "./ui/input";\nimport { Button } from "./ui/button";\nimport { X } from "lucide-react";\nimport { cn } from "@/lib/utils";\nimport { cva, type VariantProps } from "class-variance-authority";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from "@/components/ui/command";\nimport { toast } from "./ui/use-toast";\nimport { v4 as uuid } from "uuid";\n\nconst tagVariants = cva(\n "transition-all border inline-flex items-center text-sm pl-2 rounded-md",\n {\n variants: {\n variant: {\n default: "bg-secondary text-secondary-foreground hover:bg-secondary/80",\n primary:\n "bg-primary border-primary text-primary-foreground hover:bg-primary/90",\n destructive:\n "bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90",\n },\n size: {\n sm: "text-xs h-7",\n md: "text-sm h-8",\n lg: "text-base h-9",\n xl: "text-lg h-10",\n },\n shape: {\n default: "rounded-sm",\n rounded: "rounded-lg",\n square: "rounded-none",\n pill: "rounded-full",\n },\n borderStyle: {\n default: "border-solid",\n none: "border-none",\n },\n textCase: {\n uppercase: "uppercase",\n lowercase: "lowercase",\n capitalize: "capitalize",\n },\n interaction: {\n clickable: "cursor-pointer hover:shadow-md",\n nonClickable: "cursor-default",\n },\n animation: {\n none: "",\n fadeIn: "animate-fadeIn",\n slideIn: "animate-slideIn",\n bounce: "animate-bounce",\n },\n textStyle: {\n normal: "font-normal",\n bold: "font-bold",\n italic: "italic",\n underline: "underline",\n lineThrough: "line-through",\n },\n },\n defaultVariants: {\n variant: "default",\n size: "md",\n shape: "default",\n borderStyle: "default",\n textCase: "capitalize",\n interaction: "nonClickable",\n animation: "fadeIn",\n textStyle: "normal",\n },\n }\n);\n\nconst tagInputVariants = cva("border rounded-md flex flex-wrap gap-2", {\n variants: {\n inputFieldPostion: {\n bottom: "border-secondary",\n top: "border-primary",\n inline: "border-destructive",\n },\n },\n defaultVariants: {\n inputFieldPostion: "bottom",\n },\n});\n\nexport enum Delimiter {\n Comma = ",",\n Enter = "Enter",\n Space = " ",\n}\n\ntype OmittedInputProps = Omit<\n React.InputHTMLAttributes,\n "size" | "value"\n>;\n\nexport type Tag = {\n id: string;\n text: string;\n};\n\nexport interface TagInputProps\n extends OmittedInputProps,\n VariantProps {\n placeholder?: string;\n tags: Tag[];\n setTags: React.Dispatch>;\n enableAutocomplete?: boolean;\n autocompleteOptions?: Tag[];\n maxTags?: number;\n minTags?: number;\n readOnly?: boolean;\n disabled?: boolean;\n onTagAdd?: (tag: string) => void;\n onTagRemove?: (tag: string) => void;\n allowDuplicates?: boolean;\n validateTag?: (tag: string) => boolean;\n delimiter?: Delimiter;\n showCount?: boolean;\n placeholderWhenFull?: string;\n sortTags?: boolean;\n delimiterList?: string[];\n truncate?: number;\n minLength?: number;\n maxLength?: number;\n value?: string | number | readonly string[] | { id: string; text: string }[];\n autocompleteFilter?: (option: string) => boolean;\n direction?: "row" | "column";\n onInputChange?: (value: string) => void;\n customTagRenderer?: (tag: Tag) => React.ReactNode;\n onFocus?: React.FocusEventHandler;\n onBlur?: React.FocusEventHandler;\n onTagClick?: (tag: Tag) => void;\n draggable?: boolean;\n inputFieldPostion?: "bottom" | "top" | "inline";\n clearAll?: boolean;\n onClearAll?: () => void;\n inputProps?: React.InputHTMLAttributes;\n}\n\nconst TagInput = React.forwardRef(\n (props, ref) => {\n const {\n id,\n placeholder,\n tags,\n setTags,\n variant,\n size,\n shape,\n className,\n enableAutocomplete,\n autocompleteOptions,\n maxTags,\n delimiter = Delimiter.Comma,\n onTagAdd,\n onTagRemove,\n allowDuplicates,\n showCount,\n validateTag,\n placeholderWhenFull = "Max tags reached",\n sortTags,\n delimiterList,\n truncate,\n autocompleteFilter,\n borderStyle,\n textCase,\n interaction,\n animation,\n textStyle,\n minLength,\n maxLength,\n direction = "row",\n onInputChange,\n customTagRenderer,\n onFocus,\n onBlur,\n onTagClick,\n draggable = false,\n inputFieldPostion = "bottom",\n clearAll = false,\n onClearAll,\n inputProps = {},\n } = props;\n\n const [inputValue, setInputValue] = React.useState("");\n const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));\n const inputRef = React.useRef(null);\n const [draggedTagId, setDraggedTagId] = React.useState(null);\n\n if (\n (maxTags !== undefined && maxTags < 0) ||\n (props.minTags !== undefined && props.minTags < 0)\n ) {\n console.warn("maxTags and minTags cannot be less than 0");\n toast({\n title: "maxTags and minTags cannot be less than 0",\n description:\n "Please set maxTags and minTags to a value greater than or equal to 0",\n variant: "destructive",\n });\n return null;\n }\n\n const handleInputChange = (e: React.ChangeEvent) => {\n const newValue = e.target.value;\n setInputValue(newValue);\n onInputChange?.(newValue);\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (\n delimiterList\n ? delimiterList.includes(e.key)\n : e.key === delimiter || e.key === Delimiter.Enter\n ) {\n e.preventDefault();\n const newTagText = inputValue.trim();\n\n if (validateTag && !validateTag(newTagText)) {\n return;\n }\n\n if (minLength && newTagText.length < minLength) {\n console.warn("Tag is too short");\n toast({\n title: "Tag is too short",\n description: "Please enter a tag with more characters",\n variant: "destructive",\n });\n return;\n }\n\n // Validate maxLength\n if (maxLength && newTagText.length > maxLength) {\n toast({\n title: "Tag is too long",\n description: "Please enter a tag with less characters",\n variant: "destructive",\n });\n console.warn("Tag is too long");\n return;\n }\n\n const newTagId = uuid();\n\n if (\n newTagText &&\n (allowDuplicates || !tags.some((tag) => tag.text === newTagText)) &&\n (maxTags === undefined || tags.length < maxTags)\n ) {\n setTags([...tags, { id: newTagId, text: newTagText }]);\n onTagAdd?.(newTagText);\n setTagCount((prevTagCount) => prevTagCount + 1);\n }\n setInputValue("");\n }\n };\n\n const removeTag = (idToRemove: string) => {\n setTags(tags.filter((tag) => tag.id !== idToRemove));\n onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || "");\n setTagCount((prevTagCount) => prevTagCount - 1);\n };\n\n const handleDragStart = (id: string) => {\n setDraggedTagId(id);\n };\n\n const handleDragOver = (e: React.DragEvent) => {\n e.preventDefault(); // Necessary to allow dropping\n };\n\n const handleDrop = (id: string) => {\n if (draggedTagId === null) return;\n\n const draggedTagIndex = tags.findIndex((tag) => tag.id === draggedTagId);\n const dropTargetIndex = tags.findIndex((tag) => tag.id === id);\n\n if (draggedTagIndex === dropTargetIndex) return;\n\n const newTags = [...tags];\n const [reorderedTag] = newTags.splice(draggedTagIndex, 1);\n newTags.splice(dropTargetIndex, 0, reorderedTag);\n\n setTags(newTags);\n setDraggedTagId(null);\n };\n\n const handleClearAll = () => {\n onClearAll?.();\n };\n\n const filteredAutocompleteOptions = autocompleteFilter\n ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))\n : autocompleteOptions;\n\n const displayedTags = sortTags ? [...tags].sort() : tags;\n\n const truncatedTags = truncate\n ? tags.map((tag) => ({\n id: tag.id,\n text:\n tag.text?.length > truncate\n ? `${tag.text.substring(0, truncate)}...`\n : tag.text,\n }))\n : displayedTags;\n\n return (\n \n \n {truncatedTags.map((tagObj) =>\n customTagRenderer ? (\n customTagRenderer(tagObj)\n ) : (\n handleDragStart(tagObj.id)}\n onDragOver={handleDragOver}\n onDrop={() => handleDrop(tagObj.id)}\n className={cn(\n tagVariants({\n variant,\n size,\n shape,\n borderStyle,\n textCase,\n interaction,\n animation,\n textStyle,\n }),\n {\n "justify-between": direction === "column",\n "cursor-pointer": draggable,\n }\n )}\n onClick={() => onTagClick?.(tagObj)}\n >\n {tagObj.text}\n {\n e.stopPropagation(); // Prevent event from bubbling up to the tag span\n removeTag(tagObj.id);\n }}\n className={cn("py-1 px-3 h-full hover:bg-transparent")}\n >\n \n \n \n )\n )}\n \n {enableAutocomplete ? (\n
\n \n = maxTags\n ? placeholderWhenFull\n : placeholder\n }\n ref={inputRef}\n value={inputValue}\n disabled={maxTags !== undefined && tags.length >= maxTags}\n onFocus={onFocus}\n onChangeCapture={handleInputChange}\n onKeyDown={handleKeyDown}\n onBlur={onBlur}\n />\n \n No results found.\n \n {filteredAutocompleteOptions?.map((optionObj) => (\n = maxTags\n ? "cursor-not-allowed"\n : "cursor-pointer"\n }`}\n >\n = maxTags\n ? "cursor-not-allowed"\n : "cursor-pointer"\n }`}\n onClick={() => {\n if (\n optionObj.text &&\n (allowDuplicates ||\n !tags.some(\n (tag) => tag.text === optionObj.text\n )) &&\n (maxTags === undefined || tags.length < maxTags)\n ) {\n setTags([...tags, optionObj]);\n onTagAdd?.(optionObj.text);\n setTagCount((prevTagCount) => prevTagCount + 1);\n }\n }}\n >\n {optionObj.text}\n
\n \n ))}\n \n \n \n {maxTags && (\n
\n \n {`${tagCount}`}/{`${maxTags}`}\n \n
\n )}\n \n ) : (\n
\n = maxTags\n ? placeholderWhenFull\n : placeholder\n }\n value={inputValue}\n onChange={handleInputChange}\n onKeyDown={handleKeyDown}\n onFocus={onFocus}\n onBlur={onBlur}\n {...inputProps}\n className={className}\n autoComplete={enableAutocomplete ? "on" : "off"}\n list={enableAutocomplete ? "autocomplete-options" : undefined}\n disabled={maxTags !== undefined && tags.length >= maxTags}\n />\n {showCount && maxTags && (\n
\n \n {`${tagCount}`}/{`${maxTags}`}\n \n
\n )}\n
\n )}\n {clearAll && (\n \n )}\n \n );\n }\n);\n\nTagInput.displayName = "TagInput";\n\nexport { TagInput };\n'; - -export const tagInputDemoCode = - 'import {\n Form,\n FormControl,\n FormDescription,\n FormField,\n FormItem,\n FormLabel,\n FormMessage,\n} from "@/components/ui/form"\nimport { Tag, TagInput } from \'@/components/tag-input\'\nimport Link from \'next/link\'\nimport { Button, buttonVariants } from "@/components/ui/button"\nimport { z } from "zod"\nimport { useForm } from "react-hook-form"\nimport { zodResolver } from "@hookform/resolvers/zod"\nimport React from "react"\nimport { toast } from "@/components/ui/use-toast"\n\nconst FormSchema = z.object({\n topics: z.array(z.object({\n id: z.string(),\n text: z.string()\n })),\n});\n \nexport default function Hero(){\n\n const form = useForm>({\n resolver: zodResolver(FormSchema)\n })\n\n const [tags, setTags] = React.useState([]);\n\n const { setValue } = form;\n\n function onSubmit(data: z.infer) {\n toast({\n title: "You submitted the following values:",\n description: (\n
\n              {JSON.stringify(data, null, 2)}\n            
\n ),\n })\n }\n\n return (\n
\n
\n

Shadcn Tag Input

\n

An implementation of a Tag Input component built on top of Shadcn UI's input component.

\n
\n \n Try it out\n \n \n Github\n \n
\n
\n\n
\n
\n
\n
\n \n (\n \n Topics\n \n {\n setTags(newTags);\n setValue("topics", newTags as [Tag, ...Tag[]]);\n }} \n />\n \n \n These are the topics that you're interested in.\n \n \n \n )}\n />\n \n \n \n
\n
\n
\n
\n );\n}'; diff --git a/components/tag/auto-complete.tsx b/components/tag/autocomplete.tsx similarity index 100% rename from components/tag/auto-complete.tsx rename to components/tag/autocomplete.tsx diff --git a/content/snippets/autocomplete.mdx b/content/snippets/autocomplete.mdx new file mode 100644 index 0000000..c1c23bf --- /dev/null +++ b/content/snippets/autocomplete.mdx @@ -0,0 +1,65 @@ +--- +file: auto-complete.tsx +order: 2 +--- + +```tsx +import React from "react"; +import { + Command, + CommandList, + CommandItem, + CommandGroup, + CommandEmpty, +} from "@/components/ui/command"; +import { type Tag as TagType } from "./tag-input"; + +type AutocompleteProps = { + tags: TagType[]; + setTags: React.Dispatch>; + autocompleteOptions: TagType[]; + maxTags?: number; + onTagAdd?: (tag: string) => void; + allowDuplicates: boolean; + children: React.ReactNode; +}; + +export const Autocomplete: React.FC = ({ + tags, + setTags, + autocompleteOptions, + maxTags, + onTagAdd, + allowDuplicates, + children, +}) => { + return ( + + {children} + + No results found. + + {autocompleteOptions.map((option) => ( + +
{ + if (maxTags && tags.length >= maxTags) return; + if ( + !allowDuplicates && + tags.some((tag) => tag.text === option.text) + ) + return; + setTags([...tags, option]); + onTagAdd?.(option.text); + }} + > + {option.text} +
+
+ ))} +
+
+
+ ); +}; +``` diff --git a/content/snippets/tag-input-demo.mdx b/content/snippets/tag-input-demo.mdx new file mode 100644 index 0000000..1768bf1 --- /dev/null +++ b/content/snippets/tag-input-demo.mdx @@ -0,0 +1,128 @@ +--- +file: tag-input-demo.tsx +order: 5 +--- + +```tsx +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Tag, TagInput } from "@/components/tag-input"; +import Link from "next/link"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React from "react"; +import { toast } from "@/components/ui/use-toast"; + +const FormSchema = z.object({ + topics: z.array( + z.object({ + id: z.string(), + text: z.string(), + }) + ), +}); + +export default function Hero() { + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + const [tags, setTags] = React.useState([]); + + const { setValue } = form; + + function onSubmit(data: z.infer) { + toast({ + title: "You submitted the following values:", + description: ( +
+          {JSON.stringify(data, null, 2)}
+        
+ ), + }); + } + + return ( +
+
+

+ Shadcn Tag Input +

+

+ An implementation of a Tag Input component built on top of Shadcn + UI's input component. +

+
+ + Try it out + + + Github + +
+
+ +
+
+
+
+ + ( + + Topics + + { + setTags(newTags); + setValue("topics", newTags as [Tag, ...Tag[]]); + }} + /> + + + These are the topics that you're interested in. + + + + )} + /> + + + +
+
+
+
+ ); +} +``` diff --git a/content/snippets/tag-input.mdx b/content/snippets/tag-input.mdx new file mode 100644 index 0000000..3b2a973 --- /dev/null +++ b/content/snippets/tag-input.mdx @@ -0,0 +1,430 @@ +--- +file: tag-input.tsx +order: 4 +--- + +```tsx +"use client"; + +import React from "react"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import { type VariantProps } from "class-variance-authority"; +import { CommandInput } from "@/components/ui/command"; +import { toast } from "../ui/use-toast"; +import { v4 as uuid } from "uuid"; +import { TagPopover } from "./tag-popover"; +import { TagList } from "./tag-list"; +import { tagVariants } from "./tag"; +import { Autocomplete } from "./auto-complete"; + +export enum Delimiter { + Comma = ",", + Enter = "Enter", + Space = " ", +} + +type OmittedInputProps = Omit< + React.InputHTMLAttributes, + "size" | "value" +>; + +export type Tag = { + id: string; + text: string; +}; + +export interface TagInputProps + extends OmittedInputProps, + VariantProps { + placeholder?: string; + tags: Tag[]; + setTags: React.Dispatch>; + enableAutocomplete?: boolean; + autocompleteOptions?: Tag[]; + maxTags?: number; + minTags?: number; + readOnly?: boolean; + disabled?: boolean; + onTagAdd?: (tag: string) => void; + onTagRemove?: (tag: string) => void; + allowDuplicates?: boolean; + validateTag?: (tag: string) => boolean; + delimiter?: Delimiter; + showCount?: boolean; + placeholderWhenFull?: string; + sortTags?: boolean; + delimiterList?: string[]; + truncate?: number; + minLength?: number; + maxLength?: number; + usePopoverForTags?: boolean; + value?: string | number | readonly string[] | { id: string; text: string }[]; + autocompleteFilter?: (option: string) => boolean; + direction?: "row" | "column"; + onInputChange?: (value: string) => void; + customTagRenderer?: (tag: Tag) => React.ReactNode; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + onTagClick?: (tag: Tag) => void; + draggable?: boolean; + inputFieldPostion?: "bottom" | "top" | "inline"; + clearAll?: boolean; + onClearAll?: () => void; + inputProps?: React.InputHTMLAttributes; +} + +const TagInput = React.forwardRef( + (props, ref) => { + const { + id, + placeholder, + tags, + setTags, + variant, + size, + shape, + className, + enableAutocomplete, + autocompleteOptions, + maxTags, + delimiter = Delimiter.Comma, + onTagAdd, + onTagRemove, + allowDuplicates, + showCount, + validateTag, + placeholderWhenFull = "Max tags reached", + sortTags, + delimiterList, + truncate, + autocompleteFilter, + borderStyle, + textCase, + interaction, + animation, + textStyle, + minLength, + maxLength, + direction = "row", + onInputChange, + customTagRenderer, + onFocus, + onBlur, + onTagClick, + draggable = false, + inputFieldPostion = "bottom", + clearAll = false, + onClearAll, + usePopoverForTags = false, + inputProps = {}, + } = props; + + const [inputValue, setInputValue] = React.useState(""); + const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); + const inputRef = React.useRef(null); + const [draggedTagId, setDraggedTagId] = React.useState(null); + + if ( + (maxTags !== undefined && maxTags < 0) || + (props.minTags !== undefined && props.minTags < 0) + ) { + console.warn("maxTags and minTags cannot be less than 0"); + toast({ + title: "maxTags and minTags cannot be less than 0", + description: + "Please set maxTags and minTags to a value greater than or equal to 0", + variant: "destructive", + }); + return null; + } + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + onInputChange?.(newValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + delimiterList + ? delimiterList.includes(e.key) + : e.key === delimiter || e.key === Delimiter.Enter + ) { + e.preventDefault(); + const newTagText = inputValue.trim(); + + if (validateTag && !validateTag(newTagText)) { + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn("Tag is too short"); + toast({ + title: "Tag is too short", + description: "Please enter a tag with more characters", + variant: "destructive", + }); + return; + } + + // Validate maxLength + if (maxLength && newTagText.length > maxLength) { + toast({ + title: "Tag is too long", + description: "Please enter a tag with less characters", + variant: "destructive", + }); + console.warn("Tag is too long"); + return; + } + + const newTagId = uuid(); + + if ( + newTagText && + (allowDuplicates || !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + } + setInputValue(""); + } + }; + + const removeTag = (idToRemove: string) => { + setTags(tags.filter((tag) => tag.id !== idToRemove)); + onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || ""); + setTagCount((prevTagCount) => prevTagCount - 1); + }; + + const handleDragStart = (id: string) => { + setDraggedTagId(id); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); // Necessary to allow dropping + }; + + const handleDrop = (id: string) => { + if (draggedTagId === null) return; + + const draggedTagIndex = tags.findIndex((tag) => tag.id === draggedTagId); + const dropTargetIndex = tags.findIndex((tag) => tag.id === id); + + if (draggedTagIndex === dropTargetIndex) return; + + const newTags = [...tags]; + const [reorderedTag] = newTags.splice(draggedTagIndex, 1); + newTags.splice(dropTargetIndex, 0, reorderedTag); + + setTags(newTags); + setDraggedTagId(null); + }; + + const handleClearAll = () => { + onClearAll?.(); + }; + + const filteredAutocompleteOptions = autocompleteFilter + ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) + : autocompleteOptions; + + const displayedTags = sortTags ? [...tags].sort() : tags; + + const truncatedTags = truncate + ? tags.map((tag) => ({ + id: tag.id, + text: + tag.text?.length > truncate + ? `${tag.text.substring(0, truncate)}...` + : tag.text, + })) + : displayedTags; + + return ( +
+ {!usePopoverForTags ? ( + + ) : null} + {enableAutocomplete ? ( +
+ + {!usePopoverForTags ? ( + = maxTags + ? placeholderWhenFull + : placeholder + } + ref={inputRef} + value={inputValue} + disabled={maxTags !== undefined && tags.length >= maxTags} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={onFocus} + onBlur={onBlur} + className="w-full" + /> + ) : ( + + = maxTags + ? placeholderWhenFull + : placeholder + } + ref={inputRef} + value={inputValue} + disabled={maxTags !== undefined && tags.length >= maxTags} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={onFocus} + onBlur={onBlur} + className="w-full" + /> + + )} + +
+ ) : ( +
+ {!usePopoverForTags ? ( + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={onFocus} + onBlur={onBlur} + {...inputProps} + className={className} + autoComplete={enableAutocomplete ? "on" : "off"} + list={enableAutocomplete ? "autocomplete-options" : undefined} + disabled={maxTags !== undefined && tags.length >= maxTags} + /> + ) : ( + + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={onFocus} + onBlur={onBlur} + {...inputProps} + className={className} + autoComplete={enableAutocomplete ? "on" : "off"} + list={enableAutocomplete ? "autocomplete-options" : undefined} + disabled={maxTags !== undefined && tags.length >= maxTags} + /> + + )} +
+ )} + {showCount && maxTags && ( +
+ + {`${tagCount}`}/{`${maxTags}`} + +
+ )} + {clearAll && ( + + )} +
+ ); + } +); + +TagInput.displayName = "TagInput"; + +export { TagInput }; +``` diff --git a/content/snippets/tag-list.mdx b/content/snippets/tag-list.mdx new file mode 100644 index 0000000..6bfca79 --- /dev/null +++ b/content/snippets/tag-list.mdx @@ -0,0 +1,41 @@ +--- +file: tag-list.tsx +order: 1 +--- + +```tsx +import React from "react"; +import { type Tag as TagType } from "./tag-input"; +import { Tag, TagProps } from "./tag"; +import { cn } from "@/lib/utils"; + +export type TagListProps = { + tags: TagType[]; + customTagRenderer?: (tag: TagType) => React.ReactNode; + direction?: TagProps["direction"]; +} & Omit; + +export const TagList: React.FC = ({ + tags, + customTagRenderer, + direction, + ...tagProps +}) => { + return ( +
+ {tags.map((tagObj) => + customTagRenderer ? ( + customTagRenderer(tagObj) + ) : ( + + ) + )} +
+ ); +}; +``` diff --git a/content/snippets/tag-popover.mdx b/content/snippets/tag-popover.mdx new file mode 100644 index 0000000..a480f3e --- /dev/null +++ b/content/snippets/tag-popover.mdx @@ -0,0 +1,47 @@ +--- +file: tag-popover.tsx +order: 3 +--- + +```tsx +import React from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { type Tag as TagType } from "./tag-input"; +import { TagList, TagListProps } from "./tag-list"; + +type TagPopoverProps = { + children: React.ReactNode; + tags: TagType[]; + customTagRenderer?: (tag: TagType) => React.ReactNode; +} & TagListProps; + +export const TagPopover: React.FC = ({ + children, + tags, + customTagRenderer, + ...tagProps +}) => { + return ( + + {children} + +
+

Entered Tags

+

+ These are the tags you've entered. +

+
+ +
+
+ ); +}; +``` diff --git a/content/snippets/tag.mdx b/content/snippets/tag.mdx new file mode 100644 index 0000000..8163b17 --- /dev/null +++ b/content/snippets/tag.mdx @@ -0,0 +1,150 @@ +--- +file: tag.tsx +order: 0 +--- + +```tsx +import { X } from "lucide-react"; +import { Button } from "../ui/button"; +import { TagInputProps, type Tag as TagType } from "./tag-input"; +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +export const tagVariants = cva( + "transition-all border inline-flex items-center text-sm pl-2 rounded-md", + { + variants: { + variant: { + default: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + primary: + "bg-primary border-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90", + }, + size: { + sm: "text-xs h-7", + md: "text-sm h-8", + lg: "text-base h-9", + xl: "text-lg h-10", + }, + shape: { + default: "rounded-sm", + rounded: "rounded-lg", + square: "rounded-none", + pill: "rounded-full", + }, + borderStyle: { + default: "border-solid", + none: "border-none", + }, + textCase: { + uppercase: "uppercase", + lowercase: "lowercase", + capitalize: "capitalize", + }, + interaction: { + clickable: "cursor-pointer hover:shadow-md", + nonClickable: "cursor-default", + }, + animation: { + none: "", + fadeIn: "animate-fadeIn", + slideIn: "animate-slideIn", + bounce: "animate-bounce", + }, + textStyle: { + normal: "font-normal", + bold: "font-bold", + italic: "italic", + underline: "underline", + lineThrough: "line-through", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + shape: "default", + borderStyle: "default", + textCase: "capitalize", + interaction: "nonClickable", + animation: "fadeIn", + textStyle: "normal", + }, + } +); + +export type TagProps = { + tagObj: TagType; + variant: TagInputProps["variant"]; + size: TagInputProps["size"]; + shape: TagInputProps["shape"]; + borderStyle: TagInputProps["borderStyle"]; + textCase: TagInputProps["textCase"]; + interaction: TagInputProps["interaction"]; + animation: TagInputProps["animation"]; + textStyle: TagInputProps["textStyle"]; + handleDragStart: (id: string) => void; + handleDragOver: (event: React.DragEvent) => void; + handleDrop: (id: string) => void; + onRemoveTag: (id: string) => void; +} & Pick; + +export const Tag: React.FC = ({ + tagObj, + direction, + draggable, + handleDragStart, + handleDragOver, + handleDrop, + onTagClick, + onRemoveTag, + variant, + size, + shape, + borderStyle, + textCase, + interaction, + animation, + textStyle, +}) => { + return ( + handleDragStart(tagObj.id)} + onDragOver={handleDragOver} + onDrop={() => handleDrop(tagObj.id)} + className={cn( + tagVariants({ + variant, + size, + shape, + borderStyle, + textCase, + interaction, + animation, + textStyle, + }), + { + "justify-between": direction === "column", + "cursor-pointer": draggable, + } + )} + onClick={() => onTagClick?.(tagObj)} + > + {tagObj.text} + + + ); +}; +```