From b6800cd1bdb042e352a41dc3c8669d5f54a1df43 Mon Sep 17 00:00:00 2001 From: amnambiar Date: Tue, 14 Oct 2025 12:44:14 +0530 Subject: [PATCH 1/5] Added SelectBox with hierarchical grouped list, simple flat list, search, multiselect(show all options/ x more) --- starter_app/src/app/components/Chip.tsx | 2 +- starter_app/src/app/components/SelectBox.tsx | 381 +++++++++++++++++++ starter_app/src/app/page.tsx | 59 +++ 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 starter_app/src/app/components/SelectBox.tsx 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)} /> + + +
From 327ffb5034d9ddbadc37f30b75a6d7e5534560d9 Mon Sep 17 00:00:00 2001 From: amnambiar Date: Mon, 24 Nov 2025 09:46:50 +0530 Subject: [PATCH 2/5] Modifications to SelectBox architecture --- starter_app/src/app/[table]/page.tsx | 167 ++-- starter_app/src/app/components/Button.tsx | 3 +- starter_app/src/app/components/Checkbox.tsx | 4 +- .../app/components/ControlledTextfield.tsx | 4 +- .../src/app/components/DropdownMenu.tsx | 117 +-- starter_app/src/app/components/Icon.tsx | 2 +- starter_app/src/app/components/Menu.tsx | 83 ++ starter_app/src/app/components/RadioGroup.tsx | 4 +- starter_app/src/app/components/SelectBox.tsx | 821 ++++++++++-------- .../app/components/UncontrolledTextfield.tsx | 4 +- starter_app/src/app/page.tsx | 232 +++-- 11 files changed, 835 insertions(+), 606 deletions(-) create mode 100644 starter_app/src/app/components/Menu.tsx diff --git a/starter_app/src/app/[table]/page.tsx b/starter_app/src/app/[table]/page.tsx index 03a67b55..91d28838 100644 --- a/starter_app/src/app/[table]/page.tsx +++ b/starter_app/src/app/[table]/page.tsx @@ -2,66 +2,62 @@ import React, { useState, useMemo } from 'react'; import {TableContainer, Table, TableHeader, TableRow, TableBody, TableHead, TableCell} from '../components/Table'; import Dropdown from '../components/DropdownMenu'; +import Menu from '../components/Menu'; +import Checkbox from '../components/Checkbox'; +import RadioGroup from '../components/RadioGroup'; +import Button from '../components/Button'; import { FunnelIcon as Filter } from '@heroicons/react/24/solid'; import { ArrowsUpDownIcon as Sort } from '@heroicons/react/24/solid'; import SearchBar from '../components/SearchBox'; -const filterOptions = [ - { itemLabel: 'Non-Discarded Variables', value: 'non-discarded', suffixText: '11' }, - { itemLabel: 'Discarded Variables', value: 'discarded', suffixText: '7' }, - ] +const filterOptions = ['Non-Discarded Variables', 'Discarded Variables'] - const sortOptions = [ - { itemLabel: 'Name A-Z', value: 'name-asc' }, - { itemLabel: 'Name Z-A', value: 'name-desc' }, - { itemLabel: 'Non-Discarded Variables', value: 'non-discarded', defaultChecked: true }, - { itemLabel: 'Discarded Variables', value: 'discarded' }, - ] +const sortOptions = ['Name A-Z', 'Name Z-A', 'Non-Discarded Variables', 'Discarded Variables'] - interface RowData { - name: string; - steps: (string | null)[]; - } +interface RowData { + name: string; + steps: (string | null)[]; +} - const initialData: RowData[] = [ - { name: 'tx', - steps: ['123', '456', '789', '101', '112', '131', '415', '161', '718', '192'] }, - { name: 'datum', - steps: ['datum1', 'datum2', 'datum3', 'datum4', 'datum5', 'datum6', 'datum7', 'datum8', 'datum9', 'datum10'] }, - { name: 'redeemer', - steps: ['redeemer1', 'redeemer2', 'redeemer3', 'redeemer4', 'redeemer5', 'redeemer6', 'redeemer7', 'redeemer8', 'redeemer9', 'redeemer10'] }, - { name: 'discarded1', - steps: [ null , null, null, null, null, null, null, null, null, null] }, - { name: 'value', - steps: ['1000', '2000', '3000', '4000', '5000', '6000', '7000', '8000', '9000', '10000'] }, - { name: 'script', - steps: ['script1', 'script2', 'script3', 'script4', 'script5', 'script6', 'script7', 'script8', 'script9', 'script10'] }, - { name: 'dataHash', - steps: ['hash1', 'hash2', 'hash3', 'hash4', 'hash5', 'hash6', 'hash7', 'hash8', 'hash9', 'hash10'] }, - { name: 'inlineDatum', - steps: ['inlineDatum1', 'inlineDatum2', 'inlineDatum3', 'inlineDatum4', 'inlineDatum5', 'inlineDatum6', 'inlineDatum7', 'inlineDatum8', 'inlineDatum9', 'inlineDatum10'] }, - { name: 'inlineScript', - steps: ['inlineScript1', 'inlineScript2', 'inlineScript3', 'inlineScript4', 'inlineScript5', 'inlineScript6', 'inlineScript7', 'inlineScript8', 'inlineScript9', 'inlineScript10'] }, - { name: 'discarded2', - steps: [ null , null, null, null, null, null, null, null, null, null] }, - { name: 'referenceScript', - steps: ['referenceScript1', 'referenceScript2', 'referenceScript3', 'referenceScript4', 'referenceScript5', 'referenceScript6', 'referenceScript7', 'referenceScript8', 'referenceScript9', 'referenceScript10'] }, - { name: 'address', - steps: ['address1', 'address2', 'address3', 'address4', 'address5', 'address6', 'address7', 'address8', 'address9', 'address10'] }, - { name: 'cert', - steps: ['cert1', 'cert2', 'cert3', 'cert4', 'cert5', 'cert6', 'cert7', 'cert8', 'cert9', 'cert10'] }, - { name: 'discarded3', - steps: [ null , null, null, null, null, null, null, null, null, null] }, - { name: 'discarded4', - steps: [ null , null, null, null, null, null, null, null, null, null] }, - { name: 'discarded5', - steps: [ null , null, null, null, null, null, null, null, null, null] }, - ] +const initialData: RowData[] = [ + { name: 'tx', + steps: ['123', '456', '789', '101', '112', '131', '415', '161', '718', '192'] }, + { name: 'datum', + steps: ['datum1', 'datum2', 'datum3', 'datum4', 'datum5', 'datum6', 'datum7', 'datum8', 'datum9', 'datum10'] }, + { name: 'redeemer', + steps: ['redeemer1', 'redeemer2', 'redeemer3', 'redeemer4', 'redeemer5', 'redeemer6', 'redeemer7', 'redeemer8', 'redeemer9', 'redeemer10'] }, + { name: 'discarded1', + steps: [ null , null, null, null, null, null, null, null, null, null] }, + { name: 'value', + steps: ['1000', '2000', '3000', '4000', '5000', '6000', '7000', '8000', '9000', '10000'] }, + { name: 'script', + steps: ['script1', 'script2', 'script3', 'script4', 'script5', 'script6', 'script7', 'script8', 'script9', 'script10'] }, + { name: 'dataHash', + steps: ['hash1', 'hash2', 'hash3', 'hash4', 'hash5', 'hash6', 'hash7', 'hash8', 'hash9', 'hash10'] }, + { name: 'inlineDatum', + steps: ['inlineDatum1', 'inlineDatum2', 'inlineDatum3', 'inlineDatum4', 'inlineDatum5', 'inlineDatum6', 'inlineDatum7', 'inlineDatum8', 'inlineDatum9', 'inlineDatum10'] }, + { name: 'inlineScript', + steps: ['inlineScript1', 'inlineScript2', 'inlineScript3', 'inlineScript4', 'inlineScript5', 'inlineScript6', 'inlineScript7', 'inlineScript8', 'inlineScript9', 'inlineScript10'] }, + { name: 'discarded2', + steps: [ null , null, null, null, null, null, null, null, null, null] }, + { name: 'referenceScript', + steps: ['referenceScript1', 'referenceScript2', 'referenceScript3', 'referenceScript4', 'referenceScript5', 'referenceScript6', 'referenceScript7', 'referenceScript8', 'referenceScript9', 'referenceScript10'] }, + { name: 'address', + steps: ['address1', 'address2', 'address3', 'address4', 'address5', 'address6', 'address7', 'address8', 'address9', 'address10'] }, + { name: 'cert', + steps: ['cert1', 'cert2', 'cert3', 'cert4', 'cert5', 'cert6', 'cert7', 'cert8', 'cert9', 'cert10'] }, + { name: 'discarded3', + steps: [ null , null, null, null, null, null, null, null, null, null] }, + { name: 'discarded4', + steps: [ null , null, null, null, null, null, null, null, null, null] }, + { name: 'discarded5', + steps: [ null , null, null, null, null, null, null, null, null, null] }, +] export default function TablePage(){ const [searchTerm, setSearchTerm] = useState(''); - const [sortValue, setSortValue] = useState('non-discarded'); - const [filterValues, setFilterValues] = useState(['discarded']); + const [sortValue, setSortValue] = useState(sortOptions[2]); + const [filterValues, setFilterValues] = useState([filterOptions[1]]); const displayData = useMemo(() => { let data = initialData; @@ -71,25 +67,25 @@ export default function TablePage(){ data = data.filter(row => row.name.toLowerCase().includes(lowerTerm)); } - if (filterValues.includes("non-discarded") && !filterValues.includes("discarded")) { + if (filterValues.includes(filterOptions[0]) && !filterValues.includes(filterOptions[1])) { data = data.filter(row => row.steps.some(step => step === null)); - } else if (filterValues.includes("discarded") && !filterValues.includes("non-discarded")) { + } else if (filterValues.includes(filterOptions[1]) && !filterValues.includes(filterOptions[0])) { data = data.filter(row => row.steps.every(step => step !== null)); } else if (filterValues.length === 2) { data = []; } - if (sortValue === "name-asc") { + if (sortValue === sortOptions[0]) { data = [...data].sort((a, b) => a.name.localeCompare(b.name)); - } else if (sortValue === "name-desc") { + } else if (sortValue === sortOptions[1]) { data = [...data].sort((a, b) => b.name.localeCompare(a.name)); - } else if (sortValue === "non-discarded") { + } else if (sortValue === sortOptions[2]) { data = [...data].sort((a, b) => { const aDiscarded = a.steps.every(step => step === null) ? 1 : 0; const bDiscarded = b.steps.every(step => step === null) ? 1 : 0; return aDiscarded - bDiscarded; }); - } else if (sortValue === "discarded") { + } else if (sortValue === sortOptions[3]) { data = [...data].sort((a, b) => { const aDiscarded = a.steps.every(step => step !== null) ? 1 : 0; const bDiscarded = b.steps.every(step => step !== null) ? 1 : 0; @@ -98,7 +94,16 @@ export default function TablePage(){ } return data; - }, [searchTerm, filterValues, sortValue, initialData]); + }, [searchTerm, filterValues, sortValue]); + + const handleFilterSelect = (event: React.ChangeEvent) => { + const val = event.target.value; + if (filterValues.includes(val)) { + setFilterValues(filterValues.filter((v) => v !== val)) + } else { + setFilterValues([...filterValues, val]) + } + } return (
@@ -112,21 +117,43 @@ export default function TablePage(){ />
}} - listItems={filterOptions} type='checkbox' + btnLabel={filterValues.length === 0 ? "Filter" : filterValues.length === 1 ? "Filter: " + filterValues[0] : "Filter: " + filterValues.length + " Options" } + btnIcon={{ svg: }} position='right' - selected={filterValues} - onChange={(val) => setFilterValues(val as string[])} - /> + > + <> + {filterOptions.map((i) => + + + + )} + +
@@ -149,4 +176,4 @@ export default function TablePage(){ ) -} \ No newline at end of file +} diff --git a/starter_app/src/app/components/Button.tsx b/starter_app/src/app/components/Button.tsx index ca489128..29ace1ff 100644 --- a/starter_app/src/app/components/Button.tsx +++ b/starter_app/src/app/components/Button.tsx @@ -17,10 +17,11 @@ export default function Button({ shape = 'rounded', size = 'medium', fullWidth = false, + className, ...ButtonProps }: ButtonProps) { - return ; + return ; } const buttonVariants = cva('inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors delay-100 duration-200 ease-in-out disabled:opacity-50 disabled:pointer-events-none', { diff --git a/starter_app/src/app/components/Checkbox.tsx b/starter_app/src/app/components/Checkbox.tsx index ec47015a..a539a694 100644 --- a/starter_app/src/app/components/Checkbox.tsx +++ b/starter_app/src/app/components/Checkbox.tsx @@ -36,14 +36,14 @@ export default function Checkbox({ {checked && ( } - size={size === 'small' ? 'xsmall' : 'small'} + size='xsmall' color="surface" mode="both" strokeWidth={1.5} /> )} - {label && {label}} + {label && {label}} ); } \ No newline at end of file diff --git a/starter_app/src/app/components/ControlledTextfield.tsx b/starter_app/src/app/components/ControlledTextfield.tsx index 0825f668..88d6b915 100644 --- a/starter_app/src/app/components/ControlledTextfield.tsx +++ b/starter_app/src/app/components/ControlledTextfield.tsx @@ -93,13 +93,13 @@ export default function ControlledTextField({ placeholder={placeholder} required={required} disabled={disabled} - className='w-full border-0 bg-transparent block outline-none text-sm text-onSurface pr-0 pl-[14px] pb-[8.5px] pt-[8.5px] placeholder:text-onSurface/50' + className='w-full border-none bg-transparent block outline-none text-sm text-onSurface pr-0 pl-[14px] pb-[8.5px] pt-[8.5px] placeholder:text-onSurface/50' />

diff --git a/starter_app/src/app/components/DropdownMenu.tsx b/starter_app/src/app/components/DropdownMenu.tsx index 16c8a4f9..55e006d5 100644 --- a/starter_app/src/app/components/DropdownMenu.tsx +++ b/starter_app/src/app/components/DropdownMenu.tsx @@ -1,22 +1,10 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, ComponentProps } from 'react'; import Button from './Button'; -import Checkbox from './Checkbox'; -import {RadioButton} from './RadioGroup'; +import Menu from './Menu'; import {IconProps} from './Icon'; -interface ListItem { - itemLabel: string; - value: string; - disabled?: boolean; - suffixText?: string; -} - -export interface DropdownProps { - listItems: ListItem[]; - type?: 'checkbox' | 'radio' | 'menuItem'; - selected: string[] | string | null; - onChange?: (selected: string[] | string | null) => void; +export interface DropdownProps extends ComponentProps<'div'> { btnLabel: string; btnIcon?: IconProps; size?: 'small' | 'medium'; @@ -25,13 +13,10 @@ export interface DropdownProps { export default function Dropdown({ size = 'small', - listItems, - type = 'menuItem', - selected, - onChange, btnLabel, btnIcon, position, + ...props }: DropdownProps) { const [open, setOpen] = useState(false); const dropdownRef = useRef(null); @@ -55,55 +40,6 @@ export default function Dropdown({ }; }, []); - const handleSelect = (opt: ListItem) => { - const value = opt.value; - if (type === 'checkbox') { - const current = Array.isArray(selected) ? selected : []; - const newSelected = current.includes(value) - ? current.filter((v) => v !== value) - : [...current, value]; - onChange?.(newSelected); - } else { - onChange?.(value); - } - }; - - const handleClearAll = () => { - onChange?.([]); - }; - - const allSelected = - type === 'checkbox' && - Array.isArray(selected) && - selected.length === listItems.length; - - const noneSelected = - !selected || (Array.isArray(selected) && selected.length === 0); - - const selectedOptionLabel = listItems.find((o) => - Array.isArray(selected) - ? o.value === selected[0] - : o.value === selected - )?.itemLabel ?? btnLabel; - - let selectionText: string; - if (type === 'checkbox') { - if (allSelected) { - selectionText = 'All'; - } else if (noneSelected) { - selectionText = ''; - } else if (selected.length == 1) { - selectionText = selectedOptionLabel; - } else { - selectionText = `${selected.length} selected`; - } - } else { - selectionText = selected ? selectedOptionLabel : ''; - } - - const buttonLabel = selectionText.length - ? `${btnLabel}: ${selectionText}` - : btnLabel; return (

@@ -111,50 +47,13 @@ export default function Dropdown({ variant="primary" size={size} onClick={() => setOpen((prev) => !prev)} - content={buttonLabel} + content={btnLabel} startIcon={btnIcon} /> {open && ( -
-
-
    - {listItems.map((item) => -
  • type === 'menuItem' ? handleSelect(item) : null}> - - {type === 'checkbox' ? ( - handleSelect(item)} - /> - ) : ( - type === 'radio' ? ( - handleSelect(item)} - /> - ) : item.itemLabel - )} - - {item.suffixText && {item.suffixText}} -
  • - )} -
- - {(type === 'checkbox' && (Array.isArray(selected) && selected.length !== 0)) && ( - <> -
-
-
+ + {props.children} + )}
); diff --git a/starter_app/src/app/components/Icon.tsx b/starter_app/src/app/components/Icon.tsx index a4dd1b35..8a62a3f8 100644 --- a/starter_app/src/app/components/Icon.tsx +++ b/starter_app/src/app/components/Icon.tsx @@ -29,7 +29,7 @@ export default function Icon({ }); } -const iconVariants = cva('inline-block', { +const iconVariants = cva('inline-block overflow-visible', { variants: { color: { primary: 'text-primary', diff --git a/starter_app/src/app/components/Menu.tsx b/starter_app/src/app/components/Menu.tsx new file mode 100644 index 00000000..c08ca9ec --- /dev/null +++ b/starter_app/src/app/components/Menu.tsx @@ -0,0 +1,83 @@ +'use client'; +import React, { ComponentProps } from 'react'; +import cn from '../utils/styleUtil'; + +export interface MenuProps extends ComponentProps<'div'> { + scrollbar?: boolean; + width?: 'fit-content' | 'fit-parent'; +} + +export interface MenuItemProps extends ComponentProps<'li'> { + selected?: boolean; + condensed?: boolean; + value?: string; + highlighted?: 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 ( +
+
    + {props.children} +
+
+ ) +} + +function MenuItem({ condensed = false, selected, highlighted, ...props }: MenuItemProps) { + return ( +
  • + {props.children} +
  • +) +} + +function MenuDivider ({marginTop = true, marginBottom = true}: MenuDividerProps) { + return ( +
    + ) +} + +function MenuGroup({ label, children }: MenuGroupProps) { + return ( +
  • +
    + {label} +
    + +
      {children}
    +
  • + ); +} + +function MenuClear({ onClear, children }: MenuClearProps) { + return ( +
    + {children ?? 'Clear All'} +
    + ); +} + +Menu.Item = MenuItem; +Menu.Divider = MenuDivider; +Menu.Group = MenuGroup; +Menu.Clear = MenuClear; \ No newline at end of file diff --git a/starter_app/src/app/components/RadioGroup.tsx b/starter_app/src/app/components/RadioGroup.tsx index 590263df..ca4812f0 100644 --- a/starter_app/src/app/components/RadioGroup.tsx +++ b/starter_app/src/app/components/RadioGroup.tsx @@ -17,7 +17,7 @@ interface RadioGroupProps { } // When using this individual Radio Button Component you will need to manage the checked state and onChange handler in the parent component. -export function RadioButton({ +function RadioButton({ value, label, checked, @@ -75,3 +75,5 @@ export default function RadioGroup({ ); } + +RadioGroup.Button = RadioButton; \ No newline at end of file diff --git a/starter_app/src/app/components/SelectBox.tsx b/starter_app/src/app/components/SelectBox.tsx index cdc3e742..2a174ed1 100644 --- a/starter_app/src/app/components/SelectBox.tsx +++ b/starter_app/src/app/components/SelectBox.tsx @@ -1,381 +1,474 @@ -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); +'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(() => { + const buildMap = (node: ReactNode): Record => { + let map: Record = {}; + + Children.forEach(node, (element) => { + if (!React.isValidElement(element)) return; + + const child = element as React.ReactElement; + + if (child.type === Menu.Item && child.props.value) { + const label = typeof child.props.children === 'string' + ? child.props.children + : child.props.value; + map[child.props.value] = label; } - }; - 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; + + if (child.type === Menu.Group && child.props.children) { + map = { ...map, ...buildMap(child.props.children) }; } - // 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; + + 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(''); } - - 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); + }; + 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 { - 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 + break; } - - 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 - /> - )} -
    - ); + + setVisibleChipCount(Math.max(1, count)); }; - - const renderDropdownContent = () => { - if (filteredOptions.length === 0) { - return ( -
    - No options found. -
    - ); + + // 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; } - - 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} -
    - ); + + 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; } - }); - }; - - return ( -
    + + 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); + } + }} > - {renderChipsAndInput()} - -
    - +
    + {/* 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}} +
    + )} +
    + +
    - - {isOpen && ( -
    - {renderDropdownContent()} -
    + {enhancedChildren} + + {multiple && selected.length > 0 && ( + <> + + + Clear All + + + )} + )}
    - ); - }; - export default Select; \ No newline at end of file +
    + ); +} \ No newline at end of file diff --git a/starter_app/src/app/components/UncontrolledTextfield.tsx b/starter_app/src/app/components/UncontrolledTextfield.tsx index 18ff9821..22a8229e 100644 --- a/starter_app/src/app/components/UncontrolledTextfield.tsx +++ b/starter_app/src/app/components/UncontrolledTextfield.tsx @@ -95,14 +95,14 @@ export default function UncontrolledTextField({ placeholder={placeholder} required={required} disabled={disabled} - className='w-full border-0 bg-transparent block outline-none text-sm text-onSurface pr-0 pl-[14px] pb-[8.5px] pt-[8.5px] placeholder:text-onSurface/50' + className='w-full border-none bg-transparent block outline-none text-sm text-onSurface pr-0 pl-[14px] pb-[8.5px] pt-[8.5px] placeholder:text-onSurface/50' />

    diff --git a/starter_app/src/app/page.tsx b/starter_app/src/app/page.tsx index 3bdd6a3d..6d7fe27d 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 Icon from './components/Icon'; import Button from './components/Button'; import Checkbox from './components/Checkbox'; @@ -11,17 +11,14 @@ 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 { FunnelIcon as Filter } from '@heroicons/react/24/solid'; import { ArrowsUpDownIcon as Sort } from '@heroicons/react/24/solid'; -const dropdownOptions = [ - { itemLabel: 'Option 1', value: 'option1', suffixText: '34' }, - { itemLabel: 'Option 2', value: 'option2', suffixText: '34' }, - { itemLabel: 'Option 3', value: 'option3', suffixText: '34' }, - ] +const dropdownOptions = [ 'Option 1', 'Option 2', 'Option 3']; const selectBoxGroupedOptions = [ @@ -30,17 +27,17 @@ const selectBoxGroupedOptions = [ 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: '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: 'failed' }, - { label: 'Property 2', value: 'f3prop2', status: 'pending' }, - { label: 'Property 3', value: 'f3prop3', status: 'passed' } + { 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: []} @@ -57,16 +54,43 @@ export default function Home() { const [checkValues, setCheckValues] = useState({smallCheck: true, mediumCheck: false, disabledCheck: false}); const [radioValues, setRadioValue] = useState({smallRadio: '1', mediumRadio: '3'}); const [tabValues, setTabValues] = useState({defaultTab: 'Tab 1', iconTab: 'Tab 2'}); - const [sortValue, setSortValue] = useState('option1'); - const [filterValues, setFilterValues] = useState(['option2']); + const [sortValue, setSortValue] = useState(dropdownOptions[0]); + const [filterValues, setFilterValues] = useState([dropdownOptions[1]]); - // 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([]); + const handleFilterSelect = (event: React.ChangeEvent) => { + const val = event.target.value; + if (filterValues.includes(val)) { + setFilterValues(filterValues.filter((v) => v !== val)) + } else { + setFilterValues([...filterValues, val]) + } + } + 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, + ]); return (

    @@ -170,40 +194,140 @@ export default function Home() { ]} /> }} - listItems={dropdownOptions} - type='checkbox' - selected={filterValues} - onChange={(val) => setFilterValues(val as string[])} - /> - }} listItems={dropdownOptions} - type='radio' - selected={sortValue} - onChange={(val) => setSortValue(val as string)} - /> - - - + btnLabel={filterValues.length === 0 ? "Filter" : filterValues.length === 1 ? "Filter: " + filterValues[0] : "Filter: " + filterValues.length + " Options" } + btnIcon={{ svg: }}> + <> + {dropdownOptions.map((i) => + + + + )} + +
    From d0801e13e1d8e889816d084da395ec42fbe880d9 Mon Sep 17 00:00:00 2001 From: amnambiar Date: Mon, 24 Nov 2025 10:20:16 +0530 Subject: [PATCH 3/5] Added status-icons in grouped-select and other fixes --- starter_app/src/app/components/SelectBox.tsx | 69 ++++++++++++++++---- starter_app/src/app/page.tsx | 24 +++++-- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/starter_app/src/app/components/SelectBox.tsx b/starter_app/src/app/components/SelectBox.tsx index 2a174ed1..3deca491 100644 --- a/starter_app/src/app/components/SelectBox.tsx +++ b/starter_app/src/app/components/SelectBox.tsx @@ -41,26 +41,71 @@ export default function SelectBox({ // Build value-to-label mapping from children useEffect(() => { - const buildMap = (node: ReactNode): Record => { - let map: Record = {}; + + // 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; + const child = element as React.ReactElement; - if (child.type === Menu.Item && child.props.value) { - const label = typeof child.props.children === 'string' - ? child.props.children - : child.props.value; - map[child.props.value] = label; + // 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; + } } - if (child.type === Menu.Group && child.props.children) { - map = { ...map, ...buildMap(child.props.children) }; + // 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; }; @@ -452,7 +497,7 @@ export default function SelectBox({ {/* Dropdown menu - positioned below trigger */} {open && ( diff --git a/starter_app/src/app/page.tsx b/starter_app/src/app/page.tsx index 95a4c79c..a0d15087 100644 --- a/starter_app/src/app/page.tsx +++ b/starter_app/src/app/page.tsx @@ -15,7 +15,7 @@ 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'; @@ -93,6 +93,20 @@ export default function Home() { searchableMultiCollapsed, groupedSelection, ]); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'valid': + return ; + case 'falsified': + return ; + case 'undetermined': + return ; + default: + return null; + } + } + return (
    @@ -327,13 +341,15 @@ export default function Home() { multiple={false} searchable={true} onChange={setGroupedSelection} - className="w-full" > {selectBoxGroupedOptions.map((group) => ( {group.children.map((child) => ( - - {child.label} + +
    + {child.label} + {getStatusIcon(child.status)} +
    ))}
    From 10d0db2c20b719c474e66e118fc257ab04128e1b Mon Sep 17 00:00:00 2001 From: amnambiar Date: Wed, 26 Nov 2025 11:15:32 +0530 Subject: [PATCH 4/5] Revert size prop change --- starter_app/src/app/components/ControlledTextfield.tsx | 2 +- starter_app/src/app/components/UncontrolledTextfield.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/starter_app/src/app/components/ControlledTextfield.tsx b/starter_app/src/app/components/ControlledTextfield.tsx index 88d6b915..8aa41fbc 100644 --- a/starter_app/src/app/components/ControlledTextfield.tsx +++ b/starter_app/src/app/components/ControlledTextfield.tsx @@ -99,7 +99,7 @@ export default function ControlledTextField({

    diff --git a/starter_app/src/app/components/UncontrolledTextfield.tsx b/starter_app/src/app/components/UncontrolledTextfield.tsx index 22a8229e..0b479c32 100644 --- a/starter_app/src/app/components/UncontrolledTextfield.tsx +++ b/starter_app/src/app/components/UncontrolledTextfield.tsx @@ -102,7 +102,7 @@ export default function UncontrolledTextField({