diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 94d1ed103f..8398727720 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -1,4 +1,4 @@ -import { memo, type ReactNode } from 'react' +import { memo, type ReactNode, useState } from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' import { ArrowDown, @@ -20,6 +20,11 @@ const SEARCH_ICON = ( ) +const FILTER_POPOVER_ANIMATION_CLASSES = + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' + +const RESOURCE_MENU_EDGE_OFFSET = 6 + type SortDirection = 'asc' | 'desc' export interface ColumnOption { @@ -84,6 +89,14 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ filterTags, extras, }: ResourceOptionsBarProps) { + /** + * Coordinates the Filter popover and Sort menu as a single menu bar: clicking + * one while the other is open switches to it in a single click. Functional + * updates make the close→open ordering race-proof, so whichever menu the click + * targets wins regardless of which `onOpenChange` fires first. + */ + const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null) + const hasContent = search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0) if (!hasContent) return null @@ -123,25 +136,50 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ Filter ) : filter ? ( - - - - + + setOpenMenu((current) => (open ? 'filter' : current === 'filter' ? null : current)) + } + > + +
+ + + + {sort && ( + + setOpenMenu((current) => + open ? 'sort' : current === 'sort' ? null : current + ) + } + /> + )} +
+
{filter}
) : null} - {sort && } + {sort && (onFilterToggle || !filter) && } @@ -200,11 +238,19 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo ) }) -const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) { +interface SortDropdownProps { + config: SortConfig + /** Controlled open state — omit for standalone (uncontrolled) usage. */ + open?: boolean + /** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */ + onOpenChange?: (open: boolean) => void +} + +const SortDropdown = memo(function SortDropdown({ config, open, onOpenChange }: SortDropdownProps) { const { options, active, onSort, onClear } = config return ( - + + )} + + { + setEnabledFilter(value as 'all' | 'enabled' | 'disabled') setCurrentPage(1) setSelectedDocuments(new Set()) setIsSelectAllMode(false) }} - overlayContent={ - {enabledDisplayLabel} - } - showAllOption - allOptionLabel='All' - size='sm' - className='h-[32px] w-full rounded-md' + align='start' + fullWidth + flush /> - {enabledFilter.length > 0 && ( - - )} - + ), - [enabledFilter, enabledDisplayLabel, tagDefinitions, tagFilterEntries] + [enabledFilter, tagDefinitions, tagFilterEntries] ) const connectorBadges = @@ -975,15 +967,12 @@ export function KnowledgeBase({ const filterTags: FilterTag[] = useMemo( () => [ - ...(enabledFilter.length > 0 + ...(enabledFilter !== 'all' ? [ { - label: - enabledFilter.length === 1 - ? `Status: ${enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'}` - : 'Status: 2 selected', + label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, onRemove: () => { - setEnabledFilter([]) + setEnabledFilter('all') setCurrentPage(1) setSelectedDocuments(new Set()) setIsSelectAllMode(false) @@ -1129,7 +1118,7 @@ export function KnowledgeBase({ const emptyMessage = searchQuery ? 'No documents found' - : enabledFilter.length > 0 || activeTagFilters.length > 0 + : enabledFilter !== 'all' || activeTagFilters.length > 0 ? 'Nothing matches your filter' : undefined @@ -1441,6 +1430,18 @@ export function KnowledgeBase({ ) } +/** + * Sizes the filter popover to its content with pure CSS `max-content` (clamped to + * `[280, 420]`). Because the padding box is part of `max-content`, the `p-3` + * inset is preserved on every edge — there is no separate measured/animated outer + * layer that can disagree by a few pixels and clip the right padding. The width + * still adapts to the active filters; it just resizes instantly rather than + * animating. + */ +function AutoWidthPanel({ children }: { children: ReactNode }) { + return
{children}
+} + interface TagFilterEntry { id: string tagName: string @@ -1467,13 +1468,97 @@ interface TagFilterSectionProps { onChange: (entries: TagFilterEntry[]) => void } +interface TagFilterValueControlProps { + entry: TagFilterEntry + onChange: (patch: Partial) => void +} + +/** + * Renders the value input for a knowledge base tag filter row. + */ +function TagFilterValueControl({ entry, onChange }: TagFilterValueControlProps) { + const isBetween = entry.operator === 'between' + + if (entry.fieldType === 'date') { + if (isBetween) { + return ( +
+ onChange({ value })} + placeholder='From' + fullWidth + flush + /> + to + onChange({ valueTo: value })} + placeholder='To' + fullWidth + flush + /> +
+ ) + } + + return ( + onChange({ value })} + placeholder='Select date' + fullWidth + flush + /> + ) + } + + if (isBetween) { + return ( +
+ onChange({ value: event.target.value })} + placeholder='From' + /> + to + onChange({ valueTo: event.target.value })} + placeholder='To' + /> +
+ ) + } + + return ( + onChange({ value: event.target.value })} + placeholder={ + entry.fieldType === 'boolean' + ? 'true or false' + : entry.fieldType === 'number' + ? 'Enter number' + : 'Enter value' + } + /> + ) +} + /** - * Tag filter section rendered inside the combined filter popover + * Tag filter section rendered inside the combined filter popover. */ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectionProps) { const activeCount = entries.filter((f) => f.tagSlot && f.value.trim()).length - const tagOptions: ComboboxOption[] = tagDefinitions.map((t) => ({ + const tagOptions: ChipDropdownOption[] = tagDefinitions.map((t) => ({ value: t.displayName, label: t.displayName, })) @@ -1483,6 +1568,21 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio [entries] ) + const scrollRef = useRef(null) + const prevCountRef = useRef(filtersToShow.length) + + useEffect(() => { + if (filtersToShow.length > prevCountRef.current) { + const el = scrollRef.current + if (el) { + requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + }) + } + } + prevCountRef.current = filtersToShow.length + }, [filtersToShow.length]) + const updateEntry = (id: string, patch: Partial) => { const existing = filtersToShow.find((e) => e.id === id) if (!existing) return @@ -1516,130 +1616,94 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio if (tagDefinitions.length === 0) return null return ( -
-
- - Filter by tags - -
- {activeCount > 0 && ( - - )} - -
+ )}
-
- {filtersToShow.map((entry) => { +
+ {filtersToShow.map((entry, index) => { const operators = getOperatorsForFieldType(entry.fieldType) - const operatorOptions: ComboboxOption[] = operators.map((op) => ({ + const operatorOptions: ChipDropdownOption[] = operators.map((op) => ({ value: op.value, label: op.label, })) - const isBetween = entry.operator === 'between' return ( -
-
- +
+ {index > 0 && ( +
+ + and + +
+
+ )} +
+
+ handleTagChange(entry.id, value)} + placeholder='Select tag' + align='start' + matchTriggerWidth={false} + contentClassName='max-h-[240px] overflow-y-auto' + className='max-w-[150px]' + flush + /> + {entry.tagSlot && ( + updateEntry(entry.id, { operator: value, valueTo: '' })} + placeholder='Operator' + align='start' + matchTriggerWidth={false} + flush + /> + )} +
- handleTagChange(entry.id, v)} - placeholder='Select tag' - /> - {entry.tagSlot && ( - <> - - updateEntry(entry.id, { operator: v, valueTo: '' })} - placeholder='Select operator' - /> - - - {entry.fieldType === 'date' ? ( - isBetween ? ( -
- updateEntry(entry.id, { value: v })} - placeholder='From' - /> - to - updateEntry(entry.id, { valueTo: v })} - placeholder='To' - /> -
- ) : ( - updateEntry(entry.id, { value: v })} - placeholder='Select date' - /> - ) - ) : isBetween ? ( -
- updateEntry(entry.id, { value: e.target.value })} - placeholder='From' - className='h-[28px] text-caption' - /> - to - updateEntry(entry.id, { valueTo: e.target.value })} - placeholder='To' - className='h-[28px] text-caption' - /> -
- ) : ( - updateEntry(entry.id, { value: e.target.value })} - placeholder={ - entry.fieldType === 'boolean' - ? 'true or false' - : entry.fieldType === 'number' - ? 'Enter number' - : 'Enter value' - } - className='h-[28px] text-caption' - /> - )} - + updateEntry(entry.id, patch)} + /> )}
) })}
+ +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index b802e4b992..14bbed274f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' -import type { ComboboxOption } from '@/components/emcn' -import { Combobox, Tooltip } from '@/components/emcn' +import type { ChipDropdownOption, ChipMultiSelectOption } from '@/components/emcn' +import { Button, ChipDropdown, ChipMultiSelect, Tooltip } from '@/components/emcn' import { Database } from '@/components/emcn/icons' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import type { @@ -58,6 +58,20 @@ const COLUMNS: ResourceColumn[] = [ const DATABASE_ICON = +const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [ + { value: 'all', label: 'All' }, + { value: 'connected', label: 'With connectors' }, + { value: 'unconnected', label: 'Without connectors' }, +] + +const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ + { value: 'all', label: 'All' }, + { value: 'has-docs', label: 'Has documents' }, + { value: 'empty', label: 'Empty' }, +] + +const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' + function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { return { label: EMPTY_CELL_PLACEHOLDER } @@ -409,28 +423,7 @@ export function Knowledge() { [activeSort] ) - const connectorDisplayLabel = useMemo(() => { - if (connectorFilter.length === 0) return 'All' - if (connectorFilter.length === 1) - return connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors' - return `${connectorFilter.length} selected` - }, [connectorFilter]) - - const contentDisplayLabel = useMemo(() => { - if (contentFilter.length === 0) return 'All' - if (contentFilter.length === 1) - return contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty' - return `${contentFilter.length} selected` - }, [contentFilter]) - - const ownerDisplayLabel = useMemo(() => { - if (ownerFilter.length === 0) return 'All' - if (ownerFilter.length === 1) - return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member' - return `${ownerFilter.length} members` - }, [ownerFilter, members]) - - const memberOptions: ComboboxOption[] = useMemo( + const memberOptions: ChipMultiSelectOption[] = useMemo( () => (members ?? []).map((m) => ({ value: m.userId, @@ -451,95 +444,83 @@ export function Knowledge() { [members] ) - const hasActiveFilters = - connectorFilter.length > 0 || contentFilter.length > 0 || ownerFilter.length > 0 - const filterContent = useMemo( () => ( -
-
- Connectors - {connectorDisplayLabel} - } - showAllOption - allOptionLabel='All' - size='sm' - className='h-[32px] w-full rounded-md' +
+
+
+ Connectors + {connectorFilter.length > 0 && ( + + )} +
+ setConnectorFilter(value === 'all' ? [] : [value])} + align='start' + fullWidth + flush />
-
- Content - {contentDisplayLabel} - } - showAllOption - allOptionLabel='All' - size='sm' - className='h-[32px] w-full rounded-md' +
+
+ Content + {contentFilter.length > 0 && ( + + )} +
+ setContentFilter(value === 'all' ? [] : [value])} + align='start' + fullWidth + flush />
{memberOptions.length > 0 && ( -
- Owner - +
+ Owner + {ownerFilter.length > 0 && ( + + )} +
+ {ownerDisplayLabel} - } + values={ownerFilter} + onChange={setOwnerFilter} + allLabel='All' searchable searchPlaceholder='Search members...' - showAllOption - allOptionLabel='All' - size='sm' - className='h-[32px] w-full rounded-md' + align='start' + fullWidth + flush />
)} - {hasActiveFilters && ( - - )}
), - [ - connectorFilter, - contentFilter, - ownerFilter, - memberOptions, - connectorDisplayLabel, - contentDisplayLabel, - ownerDisplayLabel, - hasActiveFilters, - ] + [connectorFilter, contentFilter, ownerFilter, memberOptions] ) const filterTags: FilterTag[] = useMemo(() => { diff --git a/apps/sim/components/emcn/components/calendar/calendar.tsx b/apps/sim/components/emcn/components/calendar/calendar.tsx new file mode 100644 index 0000000000..9153d35682 --- /dev/null +++ b/apps/sim/components/emcn/components/calendar/calendar.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useMemo, useState } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/core/utils/cn' + +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const + +const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate() +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay() +} + +function pad2(value: number): string { + return value.toString().padStart(2, '0') +} + +/** + * Serializes a calendar cell to the `YYYY-MM-DD` wire format used across the + * date filters. Built from local Y/M/D parts so there is no UTC offset shift. + */ +function toDateString(year: number, month: number, day: number): string { + return `${year}-${pad2(month + 1)}-${pad2(day)}` +} + +/** + * Parses a `YYYY-MM-DD` string (or `Date`) into a local `Date`. `YYYY-MM-DD` + * is parsed as local time to avoid the off-by-one day that `new Date('2026-05-08')` + * (UTC midnight) produces in negative-offset timezones. + */ +export function parseDateValue(value: string | Date | undefined): Date | null { + if (!value) return null + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const [year, month, day] = value.split('-').map(Number) + return new Date(year, month - 1, day) + } + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +/** + * Human-readable label for a date value (e.g. `May 8, 2026`). Returns an empty + * string when there is no parseable date — callers fall back to a placeholder. + */ +export function formatDateLabel(value: string | Date | undefined): string { + const date = parseDateValue(value) + if (!date) return '' + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export interface CalendarProps { + /** Selected date as a `YYYY-MM-DD` string or `Date`. */ + value?: string | Date + /** Called with the picked date in `YYYY-MM-DD` format. */ + onChange?: (value: string) => void + /** Forwarded to the root grid container. */ + className?: string +} + +/** + * Single-month date grid aligned with the chip family — `rounded-lg` day cells, + * `--surface-active` hover, and a `primary`-chip fill on the selected day. Pair + * it with {@link ChipDatePicker} for a chip trigger, or embed it directly. + * + * @example + * + */ +export function Calendar({ value, onChange, className }: CalendarProps) { + const selected = useMemo(() => parseDateValue(value), [value]) + const today = useMemo(() => new Date(), []) + + const [view, setView] = useState(() => { + const base = selected ?? today + return { month: base.getMonth(), year: base.getFullYear() } + }) + + const cells = useMemo<(number | null)[]>(() => { + const leading = getFirstDayOfMonth(view.year, view.month) + const total = getDaysInMonth(view.year, view.month) + const result: (number | null)[] = [] + for (let i = 0; i < leading; i++) result.push(null) + for (let day = 1; day <= total; day++) result.push(day) + return result + }, [view]) + + const goToPrevMonth = () => + setView((prev) => + prev.month === 0 + ? { month: 11, year: prev.year - 1 } + : { month: prev.month - 1, year: prev.year } + ) + + const goToNextMonth = () => + setView((prev) => + prev.month === 11 + ? { month: 0, year: prev.year + 1 } + : { month: prev.month + 1, year: prev.year } + ) + + const selectDay = (day: number) => onChange?.(toDateString(view.year, view.month, day)) + + const goToToday = () => { + setView({ month: today.getMonth(), year: today.getFullYear() }) + onChange?.(toDateString(today.getFullYear(), today.getMonth(), today.getDate())) + } + + return ( +
+
+ + + {MONTHS[view.month]} {view.year} + + +
+ +
+ {WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} +
+ +
+ {cells.map((day, index) => { + if (day === null) return
+ + const cellDate = new Date(view.year, view.month, day) + const isSelected = selected ? isSameDay(cellDate, selected) : false + const isToday = isSameDay(cellDate, today) + + return ( +
+ +
+ ) + })} +
+ + +
+ ) +} diff --git a/apps/sim/components/emcn/components/chip-date-picker/chip-date-picker.tsx b/apps/sim/components/emcn/components/chip-date-picker/chip-date-picker.tsx new file mode 100644 index 0000000000..cc2f2e7883 --- /dev/null +++ b/apps/sim/components/emcn/components/chip-date-picker/chip-date-picker.tsx @@ -0,0 +1,116 @@ +'use client' + +import { forwardRef, useState } from 'react' +import * as PopoverPrimitive from '@radix-ui/react-popover' +import { Calendar, formatDateLabel } from '@/components/emcn/components/calendar/calendar' +import { chipVariants } from '@/components/emcn/components/chip/chip' +import { ChevronDown } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +/** + * Mirrors {@link ChipDropdown}'s flat-surface treatment: a 1px border makes the + * `filled` chip read as an interactive form control rather than a static pill. + */ +const TRIGGER_BORDER_CLASS = 'border border-[var(--border-1)]' + +const POPOVER_ANIMATION_CLASSES = + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' + +export interface ChipDatePickerProps { + /** Selected date as a `YYYY-MM-DD` string. */ + value?: string + /** Called with the picked date in `YYYY-MM-DD` format. */ + onChange?: (value: string) => void + /** Shown in the trigger when no date is selected. */ + placeholder?: string + /** Aligns the calendar popover relative to the trigger. */ + align?: 'start' | 'center' | 'end' + /** Disables the trigger. */ + disabled?: boolean + /** Stretch the trigger to fill its container (mirrors `Chip`'s `fullWidth`). */ + fullWidth?: boolean + /** Removes the default `mx-0.5` cluster margin (mirrors `Chip`'s `flush`). */ + flush?: boolean + /** Forwarded class for the trigger button. */ + className?: string +} + +/** + * Date counterpart to {@link ChipDropdown} — a chip-styled trigger that opens a + * {@link Calendar} in a popover. The trigger reuses `chipVariants` (filled + + * border) and the owned chevron for visual parity with the other chip controls. + * + * @example + * + */ +const ChipDatePicker = forwardRef(function ChipDatePicker( + { + value, + onChange, + placeholder = 'Select date', + align = 'start', + disabled, + fullWidth, + flush, + className, + }, + ref +) { + const [open, setOpen] = useState(false) + const label = formatDateLabel(value) + + return ( + + + + + + + { + onChange?.(next) + setOpen(false) + }} + /> + + + + ) +}) + +ChipDatePicker.displayName = 'ChipDatePicker' + +export { ChipDatePicker } diff --git a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx index 5c1d2cb94e..e3e02519af 100644 --- a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx +++ b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx @@ -112,6 +112,7 @@ const ChipDropdown = forwardRef(function C variant = 'filled', active, fullWidth, + flush, }, ref ) { @@ -149,7 +150,7 @@ const ChipDropdown = forwardRef(function C type='button' disabled={disabled} className={cn( - chipVariants({ variant, active, fullWidth }), + chipVariants({ variant, active, fullWidth, flush }), hasTriggerBorder && TRIGGER_BORDER_CLASS, className )} diff --git a/apps/sim/components/emcn/components/chip-multi-select/chip-multi-select.tsx b/apps/sim/components/emcn/components/chip-multi-select/chip-multi-select.tsx new file mode 100644 index 0000000000..abcc13b9e5 --- /dev/null +++ b/apps/sim/components/emcn/components/chip-multi-select/chip-multi-select.tsx @@ -0,0 +1,207 @@ +'use client' + +import { type ComponentType, forwardRef, type ReactNode, useMemo, useState } from 'react' +import type { VariantProps } from 'class-variance-authority' +import { chipVariants } from '@/components/emcn/components/chip/chip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearchInput, + DropdownMenuTrigger, +} from '@/components/emcn/components/dropdown-menu/dropdown-menu' +import { Check, ChevronDown } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +type ChipIcon = ComponentType<{ className?: string }> + +/** + * Single option rendered inside a {@link ChipMultiSelect}. + */ +interface ChipMultiSelectOption { + /** Stable identifier toggled in/out of the selected `values`. */ + value: string + /** Visual label rendered in the menu and (when only one is picked) the trigger. */ + label: string + /** Optional leading icon component rendered inside the menu item. */ + icon?: ChipIcon + /** Pre-rendered leading element (e.g. an avatar) — takes precedence over `icon`. */ + iconElement?: ReactNode +} + +interface ChipMultiSelectProps + extends Pick, 'fullWidth' | 'flush'> { + /** Currently selected values. Empty array reads as "all" / no filter. */ + values: string[] + /** Called with the next selected values when an option is toggled. */ + onChange: (values: string[]) => void + /** Options to render in the menu. */ + options: ReadonlyArray + /** Label shown in the trigger and as the reset row when nothing is selected. */ + allLabel?: string + /** Renders a search field at the top of the menu that filters options by label. */ + searchable?: boolean + /** Placeholder for the search field. */ + searchPlaceholder?: string + /** Aligns the menu relative to the trigger. */ + align?: 'start' | 'center' | 'end' + /** + * Whether the menu width matches the trigger's (default `true`). Set `false` + * to let the menu size to its widest item instead. + */ + matchTriggerWidth?: boolean + /** Forwarded to the inner `DropdownMenuContent`. */ + contentClassName?: string + /** Disables the trigger. */ + disabled?: boolean + /** Forwarded class for the trigger button. */ + className?: string +} + +/** + * Mirrors {@link ChipDropdown}'s flat-surface treatment — a 1px border makes + * the `filled` chip read as an interactive control rather than a static pill. + */ +const TRIGGER_BORDER_CLASS = 'border border-[var(--border-1)]' + +/** + * Multi-select counterpart to {@link ChipDropdown} — a chip trigger that opens a + * menu of toggleable options with a leading "all" reset row and an optional + * search field. The menu stays open across selections; a trailing check marks + * each active option, matching `ChipDropdown`'s single-select affordance. + * + * @example + * + */ +const ChipMultiSelect = forwardRef( + function ChipMultiSelect( + { + values, + onChange, + options, + allLabel = 'All', + searchable = false, + searchPlaceholder = 'Search...', + align = 'start', + matchTriggerWidth = true, + contentClassName, + disabled, + fullWidth, + flush, + className, + }, + ref + ) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + + const displayLabel = useMemo(() => { + if (values.length === 0) return allLabel + if (values.length === 1) return options.find((o) => o.value === values[0])?.label ?? allLabel + return `${values.length} selected` + }, [values, options, allLabel]) + + const filteredOptions = useMemo(() => { + const query = search.trim().toLowerCase() + if (!searchable || !query) return options + return options.filter((option) => option.label.toLowerCase().includes(query)) + }, [options, searchable, search]) + + const toggle = (value: string) => { + onChange(values.includes(value) ? values.filter((v) => v !== value) : [...values, value]) + } + + return ( + { + setOpen(next) + if (!next) setSearch('') + }} + > + + + + event.preventDefault() : undefined} + className={cn( + matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none', + contentClassName + )} + > + {searchable && ( + setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') setOpen(false) + }} + /> + )} + { + event.preventDefault() + onChange([]) + }} + > + {allLabel} + {values.length === 0 ? : null} + + {filteredOptions.map((option) => { + const isSelected = values.includes(option.value) + const OptionIcon = option.icon + return ( + { + event.preventDefault() + toggle(option.value) + }} + > + {option.iconElement ?? (OptionIcon ? : null)} + {option.label} + {isSelected ? : null} + + ) + })} + + + ) + } +) + +ChipMultiSelect.displayName = 'ChipMultiSelect' + +export { ChipMultiSelect } +export type { ChipMultiSelectOption, ChipMultiSelectProps } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index cd0a68541e..2851c2580e 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -15,6 +15,7 @@ export { buttonGroupItemVariants, buttonGroupVariants, } from './button-group/button-group' +export { Calendar, type CalendarProps, formatDateLabel, parseDateValue } from './calendar/calendar' export { Callout, calloutVariants } from './callout/callout' export { Checkbox, @@ -22,6 +23,7 @@ export { checkboxVariants, } from './checkbox/checkbox' export { Chip, ChipLink, type ChipLinkProps, type ChipProps, chipVariants } from './chip/chip' +export { ChipDatePicker, type ChipDatePickerProps } from './chip-date-picker/chip-date-picker' export { ChipDropdown, type ChipDropdownOption, @@ -40,6 +42,11 @@ export { type ChipModalHeaderProps, type ChipModalProps, } from './chip-modal/chip-modal' +export { + ChipMultiSelect, + type ChipMultiSelectOption, + type ChipMultiSelectProps, +} from './chip-multi-select/chip-multi-select' export { CODE_LINE_HEIGHT_PX, Code, diff --git a/apps/sim/components/emcn/components/input/input.tsx b/apps/sim/components/emcn/components/input/input.tsx index b216d67bb5..bdf162f3d6 100644 --- a/apps/sim/components/emcn/components/input/input.tsx +++ b/apps/sim/components/emcn/components/input/input.tsx @@ -31,6 +31,12 @@ const inputVariants = cva( variants: { variant: { default: '', + /** + * Aligns the input with the chip family — a 30px `rounded-lg` filled + * surface that sits flush next to {@link ChipDropdown} / {@link ChipDatePicker} + * in form rows (e.g. tag-filter values). + */ + chip: 'h-[30px] rounded-lg text-[var(--text-body)] focus-visible:border-[var(--border-focus)] dark:bg-[var(--surface-4)]', }, }, defaultVariants: {