diff --git a/src/client.ts b/src/client.ts index 0e5b108..8fb3e10 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,3 +3,4 @@ export * from "./primitives/Markdown"; export * from "./primitives/MarkdownEditor"; export * from "./components/Form"; export * from "./components/GenericProgressForm"; +export * from "./components/MultipleSelect"; diff --git a/src/components/MultipleSelect/MultipleSelect.mdx b/src/components/MultipleSelect/MultipleSelect.mdx new file mode 100644 index 0000000..55ce44c --- /dev/null +++ b/src/components/MultipleSelect/MultipleSelect.mdx @@ -0,0 +1,203 @@ +import { Meta, Story, Controls, Canvas } from "@storybook/blocks"; + +import * as MultipleSelectStories from "./MultipleSelect.stories"; + + + +# MultipleSelect + +A flexible multi-select component with grouped options, exclusive selections, and collapsible sections. + +## Props + +### Component Props + +- **`options`**: `MultipleSelectGroup[]` - Array of option groups +- **`defaultValue`**: `Record` - Initial selections per group +- **`placeholder`**: `string` - Text shown when nothing selected if defaultValue then placeholder will be ignored +- **`className`**: `string` - Popover container CSS classes +- **`variants`**: `object` - Style customizations with the following options: + - **`color`**: `"default" | "grey"` - Controls the overall color scheme + - **`size`**: `"default" | "sm"` - Controls component sizing + - **`rounded`**: `"default"` - Controls border radius styling + - **`itemsPosition`**: `"start" | "end" | "center"` - Controls alignment of items in the list + - **`headerPosition`**: `"start" | "end" | "center"` - Controls alignment of group headers + - **`triggerTextColor`**: `"default" | "red" | "green"` - Controls the trigger text color + - **`itemsColor`**: `"default" | "light-grey"` - Controls the color of items and their icons +- **`onChange`**: `(values: Record) => void` - Selection change handler + +### Group Properties (`MultipleSelectGroup`) + +- **`groupLabel?`**: `string` - Group name (omit for ungrouped items) +- **`multiple?`**: `boolean` - Allow multiple selections (default: true) +- **`collapsible?`**: `boolean` - Enable group toggle +- **`items`**: `MultipleSelectItem[]` - Array of options + +### Item Properties (`MultipleSelectItem`) + +- **`value`**: `string` - Unique identifier +- **`label`**: `string` - Display text +- **`exclusive?`**: `boolean` - Clear other selections when chosen +- **`exclusiveScope?`**: `"group" | "global"` - Clear scope for exclusive items +- **`iconType?`**: `IconType` - Optional icon identifier check `@/primitives/Icon story` +- **`icon?`**: `React.ComponentType` - Optional icon component used if no iconType is provided +- **`itemClassName?`**: `string` - Item-specific CSS classes + +--- + + +
+ +
+
+ + +## Stories + +Below are several usage examples showcasing common patterns and advanced features. + +### 1. Basic + + +
+ +
+
+ +**Description** +A single ungrouped set of items (no group label) with simple placeholder text. + +- **Options**: 3 items, no exclusivity. +- **`onChange`** logs the selection to console. + +### 2. OrderByExample + + +
+ +
+
+ +**Description** Demonstrates **exclusive items** across two single-select groups (“Order by time” and +“Order by name”). Each item has `exclusive: true, exclusiveScope: 'global'`, ensuring that if you pick +“Recent,” it clears any “A-Z” or “Z-A” selection. + +- **`defaultValue`** sets the initial selection to `["Recent"]` in the “ORDER BY TIME” group. +- **`variants`** example usage for adjusting text color, alignment, etc. + +### 3. FilterExample + + +
+ +
+
+ +**Description** A **filter** scenario with an **“All”** exclusive item for resetting. + +- “All” is in an **ungrouped** set with `exclusive: true, exclusiveScope: 'global'`. +- Two collapsible groups, “Network” and “Status,” each with `multiple: true`. +- **`defaultValue`** starts with “All” selected in the ungrouped items. + +### 4. MixedSelectionTypes + + +
+ +
+
+ +**Description** Shows a combination of: + +- A group with an **exclusive** item called “Reset All.” +- Another group that’s **multi-select** with standard options. + +This approach is useful when you have a special action (like “Reset” or “Clear All”) plus normal multi-select items. + +### 5. WithVariants + + +
+ +
+
+ +**Description** +Showcases using **all variant props** (`triggerTextColor`, `headerPosition`, `itemsPosition`, etc.) or whatever your **MultipleSelect** has to customize. + +- You can set text color, alignment, plus a collapsible group. +- Useful to see how to pass styling variants from **tailwind-variants** to the component. + +--- + +## Tips & Best Practices + +1. **Use `defaultValue`** for initial selections, or manage selections outside if you want it to be “controlled.” +2. **`exclusive`** items are great for “select all,” “select none,” or “reset.” +3. **Collapsible groups** let you nest large sets of items without overwhelming the user. + +--- + +## Conclusion + +`MultipleSelect` is highly **configurable** for your needs: from a simple single group with no advanced logic, to multi-group filters, collapsible sections, exclusive “all” items, or complex order-by pickers. + +Check out the source code & stories for additional usage details. diff --git a/src/components/MultipleSelect/MultipleSelect.stories.tsx b/src/components/MultipleSelect/MultipleSelect.stories.tsx new file mode 100644 index 0000000..9d89167 --- /dev/null +++ b/src/components/MultipleSelect/MultipleSelect.stories.tsx @@ -0,0 +1,172 @@ +import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { MultipleSelect } from "./MultipleSelect"; + +const onChange = action("onChange"); + +const meta = { + title: "Components/MultipleSelect", + component: MultipleSelect, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Basic example with single group +export const Basic: Story = { + args: { + options: [ + { + items: [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, + ], + }, + ], + placeholder: "Select options", + onChange: (values) => onChange(values), + }, +}; + +// Example with exclusive options (like order by) +export const OrderByExample: Story = { + args: { + options: [ + { + groupLabel: "ORDER BY TIME", + multiple: false, + items: ["Recent", "Oldest"].map((value) => ({ + label: value, + value, + exclusive: true, + exclusiveScope: "global", + })), + }, + { + groupLabel: "ORDER BY NAME", + multiple: false, + items: ["A-Z", "Z-A"].map((value) => ({ + label: value, + value, + exclusive: true, + exclusiveScope: "global", + })), + }, + ], + defaultValue: { "ORDER BY TIME": ["Recent"] }, + variants: { + triggerTextColor: "green", + headerPosition: "end", + itemsPosition: "end", + }, + placeholder: "Order by", + className: "w-40", + onChange: (values) => onChange(values), + }, +}; + +// Example with filters including "All" option and collapsible groups +export const FilterExample: Story = { + args: { + options: [ + { + multiple: false, + items: [ + { + label: "All", + value: "All-id", + exclusive: true, + exclusiveScope: "global", + }, + ], + }, + { + groupLabel: "Network", + multiple: true, + collapsible: true, + items: [ + { label: "Rounds on Ethereum", value: "1" }, + { label: "Rounds on Polygon", value: "137" }, + { label: "Rounds on Optimism", value: "10" }, + ], + }, + { + groupLabel: "Status", + multiple: true, + collapsible: true, + items: [ + { label: "Active", value: "active" }, + { label: "Taking Applications", value: "applications" }, + { label: "Finished", value: "finished" }, + ], + }, + ], + defaultValue: { ungrouped: ["All-id"] }, + variants: { triggerTextColor: "red" }, + placeholder: "Filter by", + className: "w-64", + onChange: (values) => onChange(values), + }, +}; + +// Example with mixed exclusive and non-exclusive options +export const MixedSelectionTypes: Story = { + args: { + options: [ + { + groupLabel: "Special Actions", + items: [ + { + label: "Reset All", + value: "reset", + exclusive: true, + exclusiveScope: "global", + }, + ], + }, + { + groupLabel: "Regular Options", + multiple: true, + items: [ + { label: "Option 1", value: "1" }, + { label: "Option 2", value: "2" }, + { label: "Option 3", value: "3" }, + ], + }, + ], + placeholder: "Select options", + onChange: (values) => onChange(values), + }, +}; + +// Example with all variant options +export const WithVariants: Story = { + args: { + options: [ + { + groupLabel: "Group 1", + multiple: true, + collapsible: true, + items: [ + { label: "Option 1", value: "1" }, + { label: "Option 2", value: "2" }, + ], + }, + ], + + variants: { + triggerTextColor: "green", + headerPosition: "end", + itemsPosition: "end", + }, + + placeholder: "Select with variants", + onChange: (values) => onChange(values), + className: "w-40", + }, +}; diff --git a/src/components/MultipleSelect/MultipleSelect.tsx b/src/components/MultipleSelect/MultipleSelect.tsx new file mode 100644 index 0000000..482fb16 --- /dev/null +++ b/src/components/MultipleSelect/MultipleSelect.tsx @@ -0,0 +1,239 @@ +"use client"; + +import * as React from "react"; + +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Command, CommandList } from "@/ui-shadcn/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui-shadcn/popover"; + +import { MultipleSelectGroupContent } from "./components"; +import { MultipleSelectItem, MultipleSelectProps } from "./types"; +import { + getGlobalExclusiveSelection, + handleExclusiveSelection, + handleNonExclusiveSelection, + handleSelectionToggle, + getGroupKey, +} from "./utils"; +import { multipleSelect } from "./variants"; + +/** + * MultipleSelect Component + * + * A flexible dropdown component that supports grouped and ungrouped items, + * exclusive and non-exclusive selections, and single/multiple selection modes. + * + * @example Basic Usage (Ungrouped) + * ```tsx + * console.log(values)} + * /> + * ``` + * + * @example Grouped Items + * ```tsx + * console.log(values)} + * /> + * ``` + * + * @example Exclusive Selection (Radio-like behavior) + * ```tsx + * + * ``` + * + * @example Global Exclusive (Resets all selections) + * ```tsx + * + * ``` + * + */ +export const MultipleSelect = ({ + variants, + options, + onChange, + defaultValue = {}, + placeholder = "Select options", + className, + ...props +}: MultipleSelectProps) => { + // ------------------ State Management ------------------ + const [selectedValues, setSelectedValues] = + React.useState>(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [collapsedGroups, setCollapsedGroups] = React.useState>({}); + + // ------------------ Style Classes ------------------ + const { + trigger, + triggerText, + triggerIcon, + content, + groupHeader, + groupHeaderIcon, + item, + itemIcon, + itemsPosition, + } = multipleSelect(variants); + + /** + * Toggles the collapsed state of a group + */ + const toggleGroupCollapse = (groupLabel: string) => { + setCollapsedGroups((prevState) => ({ + ...prevState, + [groupLabel]: !prevState[groupLabel], + })); + }; + + /** + * Handles option selection/deselection + * @param selectedItem - The item being toggled + * @param groupLabel - Optional group label for grouped items + * @param allowMultiple - Whether multiple selections are allowed + */ + const toggleOption = ( + selectedItem: MultipleSelectItem, + groupLabel?: string, + allowMultiple?: boolean, + ) => { + setSelectedValues((currentSelections) => { + let updatedSelections = { ...currentSelections }; + const groupKey = getGroupKey(groupLabel); + + // Handle non-exclusive selections first + if (!selectedItem.exclusive) { + updatedSelections = handleNonExclusiveSelection(updatedSelections, options); + } + + // Handle exclusive selections + if (selectedItem.exclusive) { + updatedSelections = handleExclusiveSelection( + updatedSelections, + groupKey, + selectedItem.exclusiveScope, + ); + } + + // Toggle the selection + updatedSelections = handleSelectionToggle( + updatedSelections, + groupKey, + selectedItem, + allowMultiple, + ); + + // Reset to default if no selections remain + const hasSelections = Object.values(updatedSelections).some((values) => values.length > 0); + if (!hasSelections) { + updatedSelections = { ...defaultValue }; + } + + onChange(updatedSelections); + return updatedSelections; + }); + }; + + /** + * Determines the display text for the select trigger + */ + const getDisplayValue = React.useCallback(() => { + const globalExclusiveSelection = getGlobalExclusiveSelection(selectedValues, options); + if (globalExclusiveSelection) return globalExclusiveSelection; + + const allSelectedValues = Object.values(selectedValues).flat(); + if (allSelectedValues.length === 0) return placeholder; + if (allSelectedValues.length === 1) { + const selectedValue = allSelectedValues[0]; + const selectedItem = options + .flatMap((group) => group.items) + .find((item) => item.value === selectedValue); + return selectedItem?.label ?? placeholder; + } + return "Multiple"; + }, [selectedValues, options, placeholder]); + + // ------------------ Render ------------------ + return ( + + +
+ {getDisplayValue()} + {isPopoverOpen ? ( + + ) : ( + + )} +
+
+ + + + + {options.map((group, idx) => { + const groupKey = getGroupKey(group.groupLabel); + return ( + + + + ); + })} + + + +
+ ); +}; diff --git a/src/components/MultipleSelect/components/MultipleSelectGroupContent.tsx b/src/components/MultipleSelect/components/MultipleSelectGroupContent.tsx new file mode 100644 index 0000000..a44a563 --- /dev/null +++ b/src/components/MultipleSelect/components/MultipleSelectGroupContent.tsx @@ -0,0 +1,110 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/solid"; + +import { cn } from "@/lib/utils"; +import { Checkbox } from "@/primitives/Checkbox"; +import { CommandItem } from "@/ui-shadcn/command"; +import { CommandGroup } from "@/ui-shadcn/command"; + +import { MultipleSelectItem } from "../types"; +import { MultipleSelectGroup } from "../types"; +import { MultipleSelectItemRow } from "./MultipleSelectItemRow"; + +/* ------------------------------------------------------------------------------------------------- + MultipleSelectGroupContent: subcomponent that handles each group (header + items) +------------------------------------------------------------------------------------------------- */ +export const MultipleSelectGroupContent = ({ + group, + groupKey, + collapsedGroups, + selectedValues, + toggleGroupCollapse, + toggleOption, + groupHeaderClasses, + groupHeaderIconClasses, + itemClasses, + itemIconClasses, +}: { + group: MultipleSelectGroup; + groupKey: string; + collapsedGroups: Record; + selectedValues: Record; + toggleGroupCollapse: (groupKey: string) => void; + toggleOption: (item: MultipleSelectItem, groupLabel?: string, multiple?: boolean) => void; + groupHeaderClasses: string; + groupHeaderIconClasses: string; + itemClasses: string; + itemIconClasses: string; +}) => { + const isGroupOpen = !collapsedGroups[groupKey]; + return ( + + + {group.collapsible ? "" : group.groupLabel} + + {group.collapsible && group.groupLabel && ( + toggleGroupCollapse(groupKey)} + > + {group.groupLabel} + {!isGroupOpen ? ( + + ) : ( + + )} + + )} + + {/* If this group is collapsed, don't show items */} + {isGroupOpen && + group.items.map((optItem) => { + const isSelected = selectedValues[groupKey]?.includes(optItem.value); + const showCheckbox = group.multiple; + + return ( + { + // Only toggle if not clicking the checkbox (which has its own handler) + if (!str.startsWith("checkbox-click")) { + toggleOption(optItem, group.groupLabel, group.multiple); + } + }} + className={cn(itemClasses, "gap-1")} + > + {/* Show a checkbox if multiple, else a single check icon if selected */} + {showCheckbox ? ( +
+ { + e.preventDefault(); + e.stopPropagation(); + toggleOption(optItem, group.groupLabel, group.multiple); + }} + data-value="checkbox-click" + /> + +
+ ) : ( +
+ {isSelected ? ( + + ) : null} + +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/src/components/MultipleSelect/components/MultipleSelectItemRow.tsx b/src/components/MultipleSelect/components/MultipleSelectItemRow.tsx new file mode 100644 index 0000000..a98676c --- /dev/null +++ b/src/components/MultipleSelect/components/MultipleSelectItemRow.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; +import { Icon } from "@/primitives/Icon"; + +import { MultipleSelectItem } from "../types"; + +/* ------------------------------------------------------------------------------------------------- + MultipleSelectItemRow: subcomponent for an individual item's label + icon +------------------------------------------------------------------------------------------------- */ +export const MultipleSelectItemRow = ({ + item, + itemIconClasses, + itemClasses, +}: { + item: MultipleSelectItem; + itemIconClasses: string; + itemClasses: string; +}) => { + const IconComponent = item.icon; + const iconType = item.iconType; + + // Icon on the right + if (item.iconPosition === "right") { + return ( +
+ {item.label} + {iconType ? ( + + ) : ( + IconComponent && + )} +
+ ); + } + + // Icon on the left (default) + return ( +
+ {iconType ? ( + + ) : ( + IconComponent && + )} + {item.label} +
+ ); +}; diff --git a/src/components/MultipleSelect/components/index.ts b/src/components/MultipleSelect/components/index.ts new file mode 100644 index 0000000..c468414 --- /dev/null +++ b/src/components/MultipleSelect/components/index.ts @@ -0,0 +1,2 @@ +export * from "./MultipleSelectItemRow"; +export * from "./MultipleSelectGroupContent"; diff --git a/src/components/MultipleSelect/index.ts b/src/components/MultipleSelect/index.ts new file mode 100644 index 0000000..09b79ca --- /dev/null +++ b/src/components/MultipleSelect/index.ts @@ -0,0 +1,2 @@ +export * from "./MultipleSelect"; +export * from "./types"; diff --git a/src/components/MultipleSelect/types.ts b/src/components/MultipleSelect/types.ts new file mode 100644 index 0000000..a358042 --- /dev/null +++ b/src/components/MultipleSelect/types.ts @@ -0,0 +1,35 @@ +import { IconType } from "@/primitives/Icon"; + +import { MultipleSelectVariantProps } from "./variants"; + +/* ------------------------------------------------------------------------------------------------- + Data interfaces for items and groups +------------------------------------------------------------------------------------------------- */ +export interface MultipleSelectItem { + label: string; + value: string; + iconType?: IconType; + icon?: React.ComponentType<{ className?: string }>; + iconPosition?: "left" | "right"; + exclusive?: boolean; + exclusiveScope?: "group" | "global"; +} + +export interface MultipleSelectGroup { + groupLabel?: string; + multiple?: boolean; + collapsible?: boolean; + items: MultipleSelectItem[]; +} + +/* ------------------------------------------------------------------------------------------------- + Our main MultipleSelect props: includes variant props, plus your existing props. +------------------------------------------------------------------------------------------------- */ +export interface MultipleSelectProps { + options: MultipleSelectGroup[]; + onChange: (values: Record) => void; + defaultValue?: Record; + placeholder?: string; + className?: string; + variants?: Partial; +} diff --git a/src/components/MultipleSelect/utils.ts b/src/components/MultipleSelect/utils.ts new file mode 100644 index 0000000..abb34ea --- /dev/null +++ b/src/components/MultipleSelect/utils.ts @@ -0,0 +1,87 @@ +import { MultipleSelectGroup, MultipleSelectItem } from "./types"; + +/** + * Gets a unique key for a group, defaults to "ungrouped" if no label provided + */ +export const getGroupKey = (groupLabel?: string) => groupLabel ?? "ungrouped"; + +/** + * Filters out global exclusive selections from the selected values + */ +export const handleNonExclusiveSelection = ( + currentSelections: Record, + availableGroups: MultipleSelectGroup[], +) => { + Object.entries(currentSelections).forEach(([groupKey, selectedValues]) => { + const groupItems = availableGroups.find((group) => getGroupKey(group.groupLabel) === groupKey) + ?.items; + const nonExclusiveValues = selectedValues.filter((selectedValue) => { + const matchingItem = groupItems?.find((item) => item.value === selectedValue); + return !(matchingItem?.exclusive && matchingItem?.exclusiveScope === "global"); + }); + currentSelections[groupKey] = nonExclusiveValues; + }); + return currentSelections; +}; + +/** + * Handles exclusive selection logic - clears all selections for global scope + * or clears specific group for group scope + */ +export const handleExclusiveSelection = ( + currentSelections: Record, + groupKey: string, + exclusiveScope: "group" | "global" = "group", +) => { + if (exclusiveScope === "global") { + return {}; + } + currentSelections[groupKey] = []; + return currentSelections; +}; + +/** + * Toggles selection state for an item, handling both single and multiple selection modes + */ +export const handleSelectionToggle = ( + currentSelections: Record, + groupKey: string, + selectedItem: MultipleSelectItem, + allowMultiple?: boolean, +) => { + if (!allowMultiple) { + const isItemSelected = currentSelections[groupKey]?.includes(selectedItem.value); + currentSelections[groupKey] = isItemSelected ? [] : [selectedItem.value]; + } else { + const groupSelections = currentSelections[groupKey] || []; + const isItemSelected = groupSelections.includes(selectedItem.value); + if (isItemSelected) { + currentSelections[groupKey] = groupSelections.filter((value) => value !== selectedItem.value); + } else { + currentSelections[groupKey] = [...groupSelections, selectedItem.value]; + } + } + return currentSelections; +}; + +/** + * Returns the label of the first globally exclusive selected item, if any + */ +export const getGlobalExclusiveSelection = ( + currentSelections: Record, + availableGroups: MultipleSelectGroup[], +) => { + return Object.entries(currentSelections) + .flatMap(([groupKey, selectedValues]) => { + const matchingGroup = availableGroups.find( + (group) => getGroupKey(group.groupLabel) === groupKey, + ); + return selectedValues.map((selectedValue) => { + const matchingItem = matchingGroup?.items.find((item) => item.value === selectedValue); + return matchingItem?.exclusive && matchingItem?.exclusiveScope === "global" + ? matchingItem.label + : null; + }); + }) + .filter(Boolean)[0]; +}; diff --git a/src/components/MultipleSelect/variants.ts b/src/components/MultipleSelect/variants.ts new file mode 100644 index 0000000..83b0c2f --- /dev/null +++ b/src/components/MultipleSelect/variants.ts @@ -0,0 +1,131 @@ +import { tv, VariantProps } from "tailwind-variants"; + +export const multipleSelect = tv({ + base: "font-ui-sans", + slots: { + /* The root trigger button */ + trigger: + "flex items-center justify-between rounded-md font-ui-sans text-body font-medium hover:bg-transparent", + + /* The text inside the trigger */ + triggerText: "text-white", + + /* The icon inside the trigger (e.g., chevron up/down) */ + triggerIcon: "size-5 transition-transform duration-200", + + /* The popover content container */ + content: "overflow-hidden rounded-2xl", + + /* Group heading row, optionally collapsible */ + groupHeader: "flex cursor-pointer items-center justify-between space-x-2 bg-transparent ", + + /* The icon for collapsing a group (chevron) */ + groupHeaderIcon: "size-5 transition-transform duration-200", + + /* The item row container (each selectable row in the list) */ + item: "flex cursor-pointer items-center", + + /* The icon within each item row (left or right position) */ + itemIcon: "flex-shrink-0", + + /* The position of the items in the list */ + itemsPosition: "justify-start", + }, + + variants: { + color: { + default: { + trigger: "bg-transparent hover:bg-transparent", + triggerIcon: "text-grey-500 ", + triggerText: "font-ui-sans font-medium", + content: "", + groupHeader: "font-medium text-black", + groupHeaderIcon: "text-black", + item: "font-ui-sans font-normal text-black", + }, + grey: { + trigger: "bg-transparent text-grey-900 hover:bg-transparent", + triggerIcon: "", + content: "font-ui-sans text-base font-normal text-black", + groupHeader: "text-grey-300", + groupHeaderIcon: "", + item: "text-grey-900", + }, + }, + size: { + default: { + trigger: "flex gap-1 bg-transparent", + triggerText: "text-body", + content: "w-full p-1", + groupHeader: "text-body", + groupHeaderIcon: "size-5", + item: "text-base", + itemIcon: "size-4", + }, + sm: {}, + }, + rounded: { + default: { + content: "rounded-2xl", + item: "rounded-lg", + }, + }, + + itemsPosition: { + start: { + itemsPosition: "justify-start", + }, + end: { + itemsPosition: "justify-end", + content: "justify-end text-end", + }, + center: { + itemsPosition: "justify-center", + content: "justify-center text-center", + }, + }, + headerPosition: { + start: { + groupHeader: "justify-start", + }, + end: { + groupHeader: "justify-end", + }, + center: { + groupHeader: "justify-center", + }, + }, + triggerTextColor: { + default: { + triggerText: "text-black", + }, + red: { + triggerText: "text-red-500", + }, + green: { + triggerText: "text-moss-700", + }, + }, + itemsColor: { + default: { + item: "text-black", + itemIcon: "text-black", + }, + "light-grey": { + item: "text-grey-500", + itemIcon: "text-grey-500", + }, + }, + }, + + defaultVariants: { + color: "default", + size: "default", + position: "start", + rounded: "default", + triggerTextColor: "default", + itemsColor: "default", + }, +}); + +export type MultipleSelectVariantProps = VariantProps; diff --git a/src/index.ts b/src/index.ts index 4b1b598..afc4bfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ export * from "./primitives/StatCardGroup"; export * from "./primitives/TextArea"; export * from "./primitives/Toast"; export * from "./primitives/VerticalTabs"; +export * from "./primitives/Switch"; +export * from "./primitives/Checkbox"; export * from "./components/IconLabel"; export * from "./components/ProgressModal"; diff --git a/src/primitives/Select/Select.tsx b/src/primitives/Select/Select.tsx index b7a7c52..38cc2c75 100644 --- a/src/primitives/Select/Select.tsx +++ b/src/primitives/Select/Select.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { SelectGroup } from "@radix-ui/react-select"; import { tv } from "tailwind-variants"; +import { IconType } from "@/primitives/Icon"; import { Select as ShadcnSelect, SelectTrigger, @@ -21,10 +22,13 @@ export interface SelectProps { items: { label: string; value: string; + icon?: React.ReactNode; + iconPosition?: "left" | "right"; + iconType?: IconType; }[]; separator?: boolean; }[]; - placeholder?: string; + placeholder?: React.ReactNode; onValueChange?: (value: string) => void; defaultValue?: string; variant?: "default" | "outlined" | "filled"; @@ -75,8 +79,10 @@ export const Select = ({ )} {group.items.map((item) => ( - + + {item.icon && item.iconPosition === "left" && item.icon} {item.label} + {item.icon && item.iconPosition === "right" && item.icon} ))} {group.separator && } diff --git a/src/primitives/index.ts b/src/primitives/index.ts index 2ad600c..04f29e7 100644 --- a/src/primitives/index.ts +++ b/src/primitives/index.ts @@ -22,3 +22,5 @@ export * from "./StatCardGroup"; export * from "./TextArea"; export * from "./Toast"; export * from "./VerticalTabs"; +export * from "./Checkbox"; +export * from "./Switch";