diff --git a/starter_app/src/app/components/Chip.tsx b/starter_app/src/app/components/Chip.tsx index bfdc86b8..2b14d0bc 100644 --- a/starter_app/src/app/components/Chip.tsx +++ b/starter_app/src/app/components/Chip.tsx @@ -44,7 +44,7 @@ export default function Chip({ ); } -const chipVariants = cva('inline-flex items-center justify-center rounded-md text-xs font-medium whitespace-nowrap p-2 text-onVariant/90 hover:text-onSurface focus-within:ring-1 focus-within:ring-primary', { +const chipVariants = cva('inline-flex items-center justify-center rounded-md text-xs font-medium whitespace-nowrap p-2 text-onVariant/90 hover:text-onSurface focus-within:ring-1 focus-within:ring-primary ml-1', { variants: { variant: { outlined: 'border border-outline-variant bg-containerLow hover:bg-container', diff --git a/starter_app/src/app/components/SelectBox.tsx b/starter_app/src/app/components/SelectBox.tsx new file mode 100644 index 00000000..cdc3e742 --- /dev/null +++ b/starter_app/src/app/components/SelectBox.tsx @@ -0,0 +1,381 @@ +import React, { + useState, + useRef, + useEffect, + useMemo, + useCallback, + } from "react"; + import { ChevronDownIcon as ChevronDown } from '@heroicons/react/24/solid'; +import Chip from "./Chip"; + + + //flat options + interface Option { + label: string; + value: string; + type?: "standard"; + status?: "passed" | "failed" | "pending"; + } + + //Group of options + interface GroupOption { + label: string; + type: "group"; + children: Option[]; + } + + /** Union type for the options array input. so that we can support both flat and grouped options */ + export type SelectOption = Option | GroupOption; + + interface SelectProps { + options: SelectOption[]; + placeholder?: string; + search?: boolean; + multiSelect?: boolean; + showAllSelected?: boolean; + onChange: (values: string[]) => void; + } + + const Select: React.FC = ({ + options: initialOptions, + placeholder = "Select", + search = false, + multiSelect = false, + showAllSelected = false, + onChange = () => {}, + }) => { + const [isOpen, setIsOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [selectedValues, setSelectedValues] = useState([]); // values of slected options + const [visibleChipCount, setVisibleChipCount] = useState(0); // For +N more logic in multi-select mode + + const componentRef = useRef(null); + const inputRef = useRef(null); + const chipsContainerRef = useRef(null); + + const theme = { + textOnSurface: "text-gray-900 dark:text-gray-100", + bgContainer: "bg-white dark:bg-gray-700", + bgContainerHigh: "bg-gray-100 dark:bg-gray-800", + borderOutline: "border-gray-300 dark:border-gray-600", + inputStyle: + "flex items-center min-h-10 w-full rounded-md border px-4 py-2 cursor-pointer transition duration-150 ease-in-out focus-within:ring-2 focus-within:ring-blue-500", + dropdownStyle: + "absolute z-30 mt-1 w-full rounded-md shadow-xl max-h-60 overflow-y-auto", + listItemStyle: + "px-4 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900 transition duration-100", + }; + + // Effect for click outside logic + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + componentRef.current && + !componentRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // parent component listener for selection changes + useEffect(() => { + onChange(selectedValues); + }, [selectedValues, onChange]); + + // --- Filtering Logic --- + const flattenedOptions = useMemo(() => { + // all options flattened into a single array for easy searching + return initialOptions.flatMap((item) => + (item as GroupOption).type === "group" && (item as GroupOption).children + ? (item as GroupOption).children.map((child) => ({ + ...child, + groupLabel: item.label, + })) + : item.type !== "group" + ? [item as Option] + : [] + ); + }, [initialOptions]); + + const filteredOptions = useMemo(() => { + if (!searchValue) return initialOptions; + + const lowerSearch = searchValue.toLowerCase(); + + // Recursive filtering function to handle group and flat options + const filterItem = (item: SelectOption): SelectOption | null => { + if (item.type === "group") { + // Group: filter children + const groupItem = item as GroupOption; + const filteredChildren = groupItem.children.filter((child) => + child.label.toLowerCase().includes(lowerSearch) + ); + // Only return the group if it has matching children + return filteredChildren.length > 0 + ? { ...groupItem, children: filteredChildren } + : null; + } + // Standard flat option check + const standardItem = item as Option; + return standardItem.label.toLowerCase().includes(lowerSearch) + ? standardItem + : null; + }; + + return initialOptions.map(filterItem).filter(Boolean) as SelectOption[]; + }, [initialOptions, searchValue]); + + const handleToggle = useCallback(() => { + setIsOpen((prev) => !prev); + // Clear search value when closing + if (isOpen) setSearchValue(""); + + // Focus input when opening and searchable + if (!isOpen && search && inputRef.current) { + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isOpen, search]); + + const handleSelect = useCallback( + (value: string) => { + // Hope you understand this logic + setSelectedValues((prev) => { + let newValues: string[]; + if (multiSelect) { + newValues = prev.includes(value) + ? prev.filter((v) => v !== value) // Remove + : [...prev, value]; // Add + } else { + newValues = [value]; + setIsOpen(false); + } + return newValues; + }); + setSearchValue(""); + }, + [multiSelect] + ); + + const handleRemoveChip = useCallback((value: string) => { + setSelectedValues((prev) => { + const newValues = prev.filter((v) => v !== value); + return newValues; + }); + }, []); + + // Memoized array of selected option objects for rendering chips and input display + const allSelectedItems = useMemo( + () => + selectedValues + .map((v) => flattenedOptions.find((o) => o.value === v)) + .filter(Boolean) as Option[], + [selectedValues, flattenedOptions] + ); + + // Effect to manage visible chip count based on container width and showAllSelected prop + useEffect(() => { + if (showAllSelected || !multiSelect || !chipsContainerRef.current) { + setVisibleChipCount(allSelectedItems.length); + return; + } + + const checkOverflow = () => { + // In a dynamic container, we use a simple heuristic for responsive estimation. + // If we have more than 2 chips, we show 2 and the overflow counter. + if (allSelectedItems.length > 2) { + setVisibleChipCount(2); + } else { + setVisibleChipCount(allSelectedItems.length); + } + }; + + // Use a delay for DOM layout stability + const timer = setTimeout(checkOverflow, 50); + window.addEventListener("resize", checkOverflow); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", checkOverflow); + }; + }, [allSelectedItems.length, multiSelect, showAllSelected]); + + const renderChip = (item: Option) => ( + + { + e.stopPropagation(); + handleRemoveChip(item.value); + }} + /> + + ); + + const renderChipsAndInput = () => { + const selectedCount = allSelectedItems.length; + const placeholderVisible = selectedCount === 0 && !searchValue; + + if (!multiSelect) { + const selectedItem = allSelectedItems[0]; + let inputDisplayValue = ""; + if (isOpen && search) { + inputDisplayValue = searchValue; // Show search input when open and searchable + } else if (selectedItem) { + inputDisplayValue = selectedItem.label; // Show selected label when closed + } + + return ( + setSearchValue(e.target.value)} + onClick={(e) => { + e.stopPropagation(); + if (!isOpen) setIsOpen(true); + }} + placeholder={placeholderVisible ? placeholder : ""} + readOnly={!search || (!isOpen && !!selectedItem)} + className={`flex-grow border-none focus:ring-0 focus:outline-none bg-transparent py-1 w-full min-w-[50px] + ${theme.textOnSurface} ${!inputDisplayValue && placeholderVisible ? "text-gray-500" : ""} + ${search ? "cursor-text" : "cursor-pointer"} + `} + /> + ); + } + + const visibleItems = + showAllSelected || visibleChipCount === selectedCount + ? allSelectedItems + : allSelectedItems.slice(0, visibleChipCount); + + const overflowCount = selectedCount - visibleItems.length; + + return ( +
+ {visibleItems.map(renderChip)} + {overflowCount > 0 && ( + + )} + {(search || placeholderVisible) && ( + setSearchValue(e.target.value)} + onClick={(e) => { + e.stopPropagation(); // Stop parent click from handling toggle + if (!isOpen) setIsOpen(true); + }} + placeholder={placeholderVisible ? placeholder : ""} + className={`flex-grow border-none focus:ring-0 focus:outline-none bg-transparent py-1 ${ + theme.textOnSurface + } ${placeholderVisible ? "w-full" : "w-auto min-w-[50px]"}`} + // Added min-width for placeholder/search input to prevent collapse + /> + )} +
+ ); + }; + + const renderDropdownContent = () => { + if (filteredOptions.length === 0) { + return ( +
+ No options found. +
+ ); + } + + return filteredOptions.map((item) => { + // checking if item is a group + if (item.type === "group") { + const groupItem = item as GroupOption; + // Hierarchical Group Header (Not selectable, only displays text) + return ( +
+
+ {groupItem.label} +
+ {/* Divider */} +
+ {/* Render subcategories (Selectable) */} + {groupItem.children.map((child: Option) => { + const isSelected = selectedValues.includes(child.value); + return ( +
handleSelect(child.value)} + > + {child.label} +
+ ); + })} +
+ ); + } else { + // Standard selectable option (no 'type: group' property) + const standardItem = item as Option; + const isSelected = selectedValues.includes(standardItem.value); + return ( +
handleSelect(standardItem.value)} + > + {standardItem.label} +
+ ); + } + }); + }; + + return ( +
+
+ {renderChipsAndInput()} + +
+ +
+
+ + {isOpen && ( +
+ {renderDropdownContent()} +
+ )} +
+ ); + }; + export default Select; \ No newline at end of file diff --git a/starter_app/src/app/page.tsx b/starter_app/src/app/page.tsx index f109abcd..36212f46 100644 --- a/starter_app/src/app/page.tsx +++ b/starter_app/src/app/page.tsx @@ -10,6 +10,8 @@ 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 { BoltIcon as Bolt } from '@heroicons/react/24/solid'; import { CheckCircleIcon as OutlineCheck } from '@heroicons/react/24/outline'; import { FunnelIcon as Filter } from '@heroicons/react/24/solid'; @@ -21,10 +23,47 @@ const dropdownOptions = [ { itemLabel: 'Option 3', value: 'option3', suffixText: '34' }, ] + +const selectBoxGroupedOptions = [ + { label: 'File 1.hs', type: 'group', children: []}, + { + label: 'File 2.hs', + type: 'group', + children: [ + { label: 'Property 1', value: 'f2prop1', status: 'passed' }, + { label: 'Property 2', value: 'f2prop2', status: 'pending' }, + { label: 'Property 3', value: 'f2prop3', status: 'pending' } + ] + }, + { label: 'File 3.hs', + type: 'group', + children: [ + { label: 'Property 1', value: 'f3prop1', status: 'failed' }, + { label: 'Property 2', value: 'f3prop2', status: 'pending' }, + { label: 'Property 3', value: 'f3prop3', status: 'passed' } + ] + }, + { 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 [sortValue, setSortValue] = useState('option1'); const [filterValues, setFilterValues] = useState(['option2']); + // State for the Grouped/Multi-select demo + const [, setGroupedSelectBoxSelection] = useState([]); + // State for the Flat/Single-select demo + const [, setFlatSelectBoxSelection] = useState([]); + // State for the Flat/multi-select demo + const [, setMultipleFlatSelectBoxSelection] = useState([]); + return (
@@ -135,6 +174,26 @@ export default function Home() { selected={sortValue} onChange={(val) => setSortValue(val as string)} /> + + +