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) => (
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {/* Dropdown menu - positioned below trigger */}
+ {open && (
+
+ )}
+
+
+ );
+}
\ 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)}
+
+
+ ))}
+
+ ))}
+
+