diff --git a/starter_app/src/app/components/Menu.tsx b/starter_app/src/app/components/Menu.tsx index 97523dbf..b7bf3165 100644 --- a/starter_app/src/app/components/Menu.tsx +++ b/starter_app/src/app/components/Menu.tsx @@ -3,43 +3,81 @@ import React, { ComponentProps } from 'react'; import cn from '../utils/styleUtil'; export interface MenuProps extends ComponentProps<'div'> { - scrollbar?: boolean; - width?: 'fit-content' | 'fit-parent'; + scrollbar?: boolean; + width?: 'fit-content' | 'fit-parent'; } -interface MenuItemProps extends ComponentProps<'li'> { - selected?: boolean; - condensed?: boolean; +export interface MenuItemProps extends ComponentProps<'li'> { + selected?: boolean; + condensed?: boolean; + value?: string; + highlighted?: boolean; } -interface MenuDividerProps extends ComponentProps<'hr'> { - marginTop?: boolean; - marginBottom?: boolean; +export interface MenuDividerProps extends ComponentProps<'hr'> { + marginTop?: boolean; + marginBottom?: boolean; +} + +export interface MenuGroupProps { + label: string; + children: React.ReactNode; +} + +export interface MenuClearProps { + onClear?: () => void; + children?: React.ReactNode; } export default function Menu ({scrollbar, width = 'fit-content', ...props}: MenuProps) { - return ( -
- -
- ) + return ( +
+ +
+ ) } -function MenuItem ({condensed = false, selected, ...props}: MenuItemProps) { - return ( -
  • - {props.children} -
  • - ) +function MenuItem({ condensed = false, selected, highlighted, ...props }: MenuItemProps) { + return ( +
  • + {props.children} +
  • +) } function MenuDivider ({marginTop = true, marginBottom = true}: MenuDividerProps) { - return ( -
    - ) + return ( +
    + ) +} + +function MenuGroup({ label, children }: MenuGroupProps) { + return ( +
  • +
    + {label} +
    + + +
  • + ); +} + +function MenuClear({ onClear, children }: MenuClearProps) { + return ( +
    + {children ?? 'Clear All'} +
    + ); } Menu.Item = MenuItem; -Menu.Divider = MenuDivider; \ No newline at end of file +Menu.Divider = MenuDivider; +Menu.Group = MenuGroup; +Menu.Clear = MenuClear; diff --git a/starter_app/src/app/components/SelectBox.tsx b/starter_app/src/app/components/SelectBox.tsx new file mode 100644 index 00000000..3deca491 --- /dev/null +++ b/starter_app/src/app/components/SelectBox.tsx @@ -0,0 +1,519 @@ +'use client'; + +import React, { ReactNode, useEffect, useRef, useState, cloneElement, Children } from 'react'; +import cn from '../utils/styleUtil'; +import Chip from './Chip'; +import Menu, { MenuClearProps, MenuGroupProps, MenuItemProps } from './Menu'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; + +interface SelectBoxProps { + label?: string; + placeholder?: string; + multiple?: boolean; + searchable?: boolean; + showAllSelected?: boolean; + className?: string; + onChange?: (values: string[]) => void; + children: ReactNode; +} + +export default function SelectBox({ + label, + placeholder = 'Select', + multiple = false, + searchable = false, + showAllSelected = false, + className, + onChange, + children, +}: SelectBoxProps) { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [visibleChipCount, setVisibleChipCount] = useState(0); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [valueToLabelMap, setValueToLabelMap] = useState>({}); + + const rootRef = useRef(null); + const inputRef = useRef(null); + const chipContainerRef = useRef(null); + const triggerRef = useRef(null); + + // Build value-to-label mapping from children + useEffect(() => { + + // Helper: Extract text label from Menu.Item children + const extractLabel = (children: ReactNode): string => { + + // Simple string + if (typeof children === 'string') { + return children; + } + + // React element with children (like
    Label
    ) + if (React.isValidElement(children)) { + const props = children.props as { children?: ReactNode }; + + if (props.children) { + const innerChildren = props.children; + + // If it's an array, find the first text element + if (Array.isArray(innerChildren)) { + for (const child of innerChildren) { + if (typeof child === 'string') return child; + if (React.isValidElement(child)) { + const childProps = child.props as { children?: ReactNode }; + if (typeof childProps.children === 'string') { + return childProps.children; + } + } + } + } + + // Single child + if (typeof innerChildren === 'string') { + return innerChildren; + } + } + } + + return ''; + }; + + // Recursive function to build value-to-label map + const buildMap = (node: ReactNode, groupLabel?: string): Record => { + const map: Record = {}; + + Children.forEach(node, (element) => { + if (!React.isValidElement(element)) return; + + const child = element as React.ReactElement; + + // Handle Menu.Item + if (child.type === Menu.Item) { + const { value, children } = child.props as MenuItemProps; + + if (value) { + const itemLabel = extractLabel(children) || value; + map[value] = groupLabel ? `${groupLabel} - ${itemLabel}` : itemLabel; + } + } + + // Handle Menu.Group - recurse with group label + if (child.type === Menu.Group) { + const { label, children } = child.props as MenuGroupProps; + Object.assign(map, buildMap(children, label)); + } + }); + + return map; + }; + + setValueToLabelMap(buildMap(children)); + }, [children]); + + // Close on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) { + setOpen(false); + setSearchTerm(''); + } + }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, []); + + // Dynamic chip calculation based on available width + useEffect(() => { + if (!multiple || !chipContainerRef.current || !triggerRef.current) { + setVisibleChipCount(selected.length); + return; + } + + if (showAllSelected) { + setVisibleChipCount(selected.length); + return; + } + + const calculateVisibleChips = () => { + const container = chipContainerRef.current; + const trigger = triggerRef.current; + if (!container || !trigger) return; + + // Account for: padding (24px) + chevron (20px) + gap (8px) + "+N more" (80px) + input (120px if searchable) + const paddingAndChevron = 24 + 20 + 16; // px-3 on both sides = 24px, chevron = 20px, safety margin = 16px + const moreButtonWidth = 80; + const inputWidth = searchable ? 120 : 0; + const reservedSpace = paddingAndChevron + moreButtonWidth + inputWidth; + + const availableWidth = trigger.offsetWidth - reservedSpace; + + let totalWidth = 0; + let count = 0; + + const chips = container.querySelectorAll('.chip-item'); + for (let i = 0; i < chips.length; i++) { + const chipWidth = (chips[i] as HTMLElement).offsetWidth + 8; // Include gap + if (totalWidth + chipWidth < availableWidth) { + totalWidth += chipWidth; + count++; + } else { + break; + } + } + + setVisibleChipCount(Math.max(1, count)); + }; + + // Calculate on mount and when selected changes + calculateVisibleChips(); + + // Recalculate on window resize + window.addEventListener('resize', calculateVisibleChips); + return () => window.removeEventListener('resize', calculateVisibleChips); + }, [selected, multiple, showAllSelected, searchable]); + + const visibleItems = + showAllSelected || visibleChipCount === selected.length + ? selected + : selected.slice(0, visibleChipCount); + + const overflowCount = selected.length - visibleItems.length; + + const toggleValue = (value: string) => { + let next: string[]; + if (multiple) { + next = selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]; + } else { + next = [value]; + setOpen(false); + } + + setSelected(next); + onChange?.(next); + setSearchTerm(''); + setHighlightedIndex(-1); + }; + + const clearAll = () => { + setSelected([]); + onChange?.([]); + }; + + // Filter children based on search + const filterChildren = (node: ReactNode, searchLower: string): { node: ReactNode; values: string[] } => { + let matchedValues: string[] = []; + + const filtered = Children.map(node, (element) => { + if (!React.isValidElement(element)) return element; + + const child = element as React.ReactElement; + + if ( + child.type === Menu.Clear || + child.type === Menu.Divider + ) { + return child; + } + + if (child.type === Menu.Item) { + const itemProps = child.props as MenuItemProps; + const itemText = typeof itemProps.children === 'string' + ? itemProps.children + : itemProps.value || ''; + + if (searchLower && !itemText.toLowerCase().includes(searchLower)) { + return null; + } + + if (itemProps.value) { + matchedValues.push(itemProps.value); + } + + return child; + } + + if (child.type === Menu.Group) { + const groupProps = child.props as MenuGroupProps; + const { node: filteredChildren, values: childValues } = filterChildren(groupProps.children, searchLower); + + if (childValues.length === 0) return null; + + matchedValues = [...matchedValues, ...childValues]; + + return cloneElement(child, { + children: filteredChildren, + }); + } + + return child; + }); + + return { node: filtered, values: matchedValues }; + }; + + // Enhance children with props + const enhanceChildren = (node: ReactNode, filteredValues: string[]): ReactNode => { + let currentIndex = -1; + + return Children.map(node, (element) => { + if (!React.isValidElement(element)) return element; + + const child = element as React.ReactElement; + + if (child.type === Menu.Clear) { + return cloneElement(child, { + onClear: () => clearAll(), + }); + } + + if (child.type === Menu.Item) { + const itemProps = child.props as MenuItemProps; + const value = itemProps.value; + + if (typeof value === 'string') { + currentIndex++; + const isHighlighted = currentIndex === highlightedIndex; + + return cloneElement(child, { + selected: selected.includes(value), + highlighted: isHighlighted, + onClick: (e: React.MouseEvent) => { + itemProps.onClick?.(e); + toggleValue(value); + }, + }); + } + + return child; + } + + if (child.type === Menu.Group) { + const groupProps = child.props as MenuGroupProps; + return cloneElement(child, { + children: enhanceChildren(groupProps.children, filteredValues), + }); + } + + return child; + }); + }; + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter')) { + e.preventDefault(); + setOpen(true); + return; + } + + if (!open) return; + + const searchLower = searchTerm.toLowerCase(); + const { values: filteredValues } = filterChildren(children, searchLower); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredValues.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredValues.length) { + toggleValue(filteredValues[highlightedIndex]); + } + } else if (e.key === 'Escape') { + setOpen(false); + setSearchTerm(''); + setHighlightedIndex(-1); + } + }; + + const searchLower = searchTerm.toLowerCase(); + const { node: filteredChildren, values: filteredValues } = filterChildren(children, searchLower); + const enhancedChildren = enhanceChildren(filteredChildren, filteredValues); + + // Reset highlight when search changes + useEffect(() => { + setHighlightedIndex(-1); + }, [searchTerm]); + + // // Render label for display + // const renderLabel = () => { + // if (multiple) { + // if (selected.length === 0) return placeholder; + // if (selected.length === 1) return valueToLabelMap[selected[0]] || selected[0]; + // return `${selected.length} selected`; + // } + // return valueToLabelMap[selected[0]] || selected[0] || placeholder; + // }; + + return ( +
    + {label && } + +
    +
    { + if (!searchable) { + setOpen((v) => !v); + } + }} + > +
    + {/* Hidden measurement container for chips */} + {multiple && !showAllSelected && ( +
    + {selected.map((val) => ( +
    + +
    + ))} +
    + )} + +
    + {/* Multiple + Searchable: Chips + input */} + {multiple && searchable ? ( + <> + {visibleItems.map((val) => ( + { + e.stopPropagation(); + toggleValue(val); + }} + /> + ))} + + {overflowCount > 0 && ( + + )} + + {/* Dynamic width based on if there are chips */} + { + setSearchTerm(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={selected.length === 0 ? placeholder : ''} + style={{ width: selected.length === 0 ? '100%' : '80px' }} + className="shrink-0 min-w-0 outline-none bg-transparent text-onSurface/90" + onClick={(e) => e.stopPropagation()} + /> + + ) : multiple && !searchable ? ( + <> + {visibleItems.length === 0 && ( + {placeholder} + )} + + {visibleItems.map((val) => ( + { + e.stopPropagation(); + toggleValue(val); + }} + /> + ))} + + {overflowCount > 0 && ( + + )} + + ) : !multiple && searchable ? ( + { + setSearchTerm(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => { + setOpen(true); + setSearchTerm(''); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="flex-1 outline-none bg-transparent text-onSurface/90 w-full" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + /* Single select: Just display the selected value or placeholder */ +
    + {selected[0] ? (valueToLabelMap[selected[0]] || selected[0]) : {placeholder}} +
    + )} +
    + + +
    +
    + + {/* Dropdown menu - positioned below trigger */} + {open && ( + + {enhancedChildren} + + {multiple && selected.length > 0 && ( + <> + + + Clear All + + + )} + + )} +
    +
    + ); +} \ No newline at end of file diff --git a/starter_app/src/app/page.tsx b/starter_app/src/app/page.tsx index 05c1276e..32c2622e 100644 --- a/starter_app/src/app/page.tsx +++ b/starter_app/src/app/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Drawer from './components/Drawer'; import Icon from './components/Icon'; import Button from './components/Button'; @@ -11,14 +11,46 @@ import Chip from './components/Chip'; import SearchBar from './components/SearchBox'; import Tabs from './components/Tabs'; import Dropdown from './components/DropdownMenu'; +import SelectBox from './components/SelectBox'; import Menu from './components/Menu'; + import { BoltIcon as Bolt } from '@heroicons/react/24/solid'; -import { CheckCircleIcon as OutlineCheck } from '@heroicons/react/24/outline'; +import { CheckCircleIcon as OutlineCheck, CheckCircleIcon, QuestionMarkCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; import { FunnelIcon as Filter } from '@heroicons/react/24/solid'; import { ArrowsUpDownIcon as Sort } from '@heroicons/react/24/solid'; const dropdownOptions = [ 'Option 1', 'Option 2', 'Option 3']; + +const selectBoxGroupedOptions = [ + { label: 'File 1.hs', type: 'group', children: []}, + { + label: 'File 2.hs', + type: 'group', + children: [ + { label: 'Property 1', value: 'f2prop1', status: 'valid' }, + { label: 'Property 2', value: 'f2prop2', status: 'undetermined' }, + { label: 'Property 3', value: 'f2prop3', status: 'undetermined' } + ] + }, + { label: 'File 3.hs', + type: 'group', + children: [ + { label: 'Property 1', value: 'f3prop1', status: 'falsified' }, + { label: 'Property 2', value: 'f3prop2', status: 'undetermined' }, + { label: 'Property 3', value: 'f3prop3', status: 'valid' } + ] + }, + { label: 'File 4.hs', type: 'group', children: []} +] + +const flatSelectBoxOptions = [ + { label: 'Double Satisfaction', value: 'doubleSatisfaction'}, + { label: 'Unit Tests', value: 'unitTests'}, + { label: 'Crash Tolerance', value: 'crashTolerance'}, + { label: 'Large Datum Attack', value: 'largeDatumAttack'}, +] + export default function Home() { const [openDrawer, setOpenDrawer] = useState(false); const [checkValues, setCheckValues] = useState({smallCheck: true, mediumCheck: false, disabledCheck: false}); @@ -36,6 +68,45 @@ export default function Home() { } } + const [flatSingleSelection, setFlatSingleSelection] = useState([]); + const [flatMultiSelection, setFlatMultiSelection] = useState([]); + const [searchableSingleSelection, setSearchableSingleSelection] = useState([]); + const [searchableMultiSelection, setSearchableMultiSelection] = useState([]); + const [searchableMultiCollapsed, setSearchableMultiCollapsed] = useState([]); + const [groupedSelection, setGroupedSelection] = useState([]); + + + useEffect(() => { + console.log('Selection Changes:', { + flatSingle: flatSingleSelection, + flatMulti: flatMultiSelection, + searchableSingle: searchableSingleSelection, + searchableMulti: searchableMultiSelection, + searchableMultiCollapsed: searchableMultiCollapsed, + grouped: groupedSelection, + }); + }, [ + flatSingleSelection, + flatMultiSelection, + searchableSingleSelection, + searchableMultiSelection, + searchableMultiCollapsed, + groupedSelection, + ]); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'valid': + return ; + case 'falsified': + return ; + case 'undetermined': + return ; + default: + return null; + } + } + return (
    @@ -120,7 +191,7 @@ export default function Home() { type="date" />
    -
    +
    }} /> }} /> )} + + {/* Flat - Single Select (Non-searchable) */} + + {flatSelectBoxOptions.map((opt) => ( + + {opt.label} + + ))} + + + + {/* Flat - multi-select */} + + {flatSelectBoxOptions.map((opt) => ( + + {opt.label} + + ))} + + + + {/* Single Select - Searchable */} + + {flatSelectBoxOptions.map((opt) => ( + + {opt.label} + + ))} + + + + {/* Multi Select - Searchable with all chips */} + + {flatSelectBoxOptions.map((opt) => ( + + {opt.label} + + ))} + + + {/* Multi Select - Searchable (showAllSelected false) */} + {/* TBD - Fix horizontal scroll when large labels */} + + {flatSelectBoxOptions.map((opt) => ( + + {opt.label} + + ))} + + + {/* Grouped Single Select */} + + {selectBoxGroupedOptions.map((group) => ( + + {group.children.map((child) => ( + +
    + {child.label} + {getStatusIcon(child.status)} +
    +
    + ))} +
    + ))} +
    +