Skip to content

Commit ff64163

Browse files
committed
feat: working commit for view all templates component
1 parent 1dd73d4 commit ff64163

12 files changed

Lines changed: 1040 additions & 38 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"devDependencies": {
2828
"@biomejs/biome": "2.1.1",
29+
"@types/human-date": "^1",
2930
"@types/jsdom": "^28",
3031
"@vitest/coverage-v8": "^4.0.18",
3132
"husky": "^9.1.7",
@@ -44,6 +45,7 @@
4445
]
4546
},
4647
"dependencies": {
48+
"human-date": "^1.4.0",
4749
"react-select": "^5.2.1"
4850
}
4951
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
type As,
3+
Box,
4+
Flex,
5+
HStack,
6+
Icon,
7+
Text,
8+
useColorModeValue,
9+
} from "@chakra-ui/react"
10+
import type * as React from "react"
11+
12+
type FilterChipsOption = {
13+
label: string
14+
value: string
15+
icon?: React.ReactNode
16+
}
17+
18+
type FilterChipsFieldProps = {
19+
id?: string
20+
value?: string[]
21+
options: FilterChipsOption[]
22+
onChange: (values: string[]) => void
23+
maxSelections?: number
24+
}
25+
26+
const toggleValue = (
27+
current: string[] = [],
28+
v: string,
29+
max?: number,
30+
): string[] => {
31+
const currentArray = Array.isArray(current) ? current : []
32+
const set = new Set(currentArray)
33+
if (set.has(v)) {
34+
set.delete(v)
35+
return Array.from(set)
36+
}
37+
if (typeof max === "number" && currentArray.length >= max) return currentArray
38+
set.add(v)
39+
return Array.from(set)
40+
}
41+
42+
export const FilterChipsField: React.FC<FilterChipsFieldProps> = ({
43+
id,
44+
value = [],
45+
options,
46+
onChange,
47+
maxSelections,
48+
}) => {
49+
const selectedBg = useColorModeValue("blue.50", "blue.900")
50+
const selectedBorder = useColorModeValue("blue.400", "blue.300")
51+
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
52+
const safeValue = Array.isArray(value) ? value : []
53+
const isMaxed =
54+
typeof maxSelections === "number" && safeValue.length >= maxSelections
55+
56+
const handleKeyDown = (
57+
e: React.KeyboardEvent<HTMLDivElement>,
58+
v: string,
59+
disabled?: boolean,
60+
) => {
61+
if (disabled) return
62+
if (e.key === " " || e.key === "Enter") {
63+
e.preventDefault()
64+
onChange(toggleValue(safeValue, v, maxSelections))
65+
}
66+
}
67+
68+
return (
69+
<HStack
70+
as="fieldset"
71+
id={id}
72+
role="group"
73+
align="center"
74+
spacing="2"
75+
wrap="wrap"
76+
>
77+
{options.map((opt) => {
78+
const isChecked = safeValue.includes(opt.value)
79+
const disabled = opt.isDisabled || (!isChecked && isMaxed)
80+
return (
81+
<Box
82+
key={opt.value}
83+
role="checkbox"
84+
aria-checked={isChecked}
85+
aria-disabled={disabled || undefined}
86+
tabIndex={disabled ? -1 : 0}
87+
onClick={() => {
88+
if (disabled) return
89+
onChange(toggleValue(safeValue, opt.value, maxSelections))
90+
}}
91+
onKeyDown={(e) => handleKeyDown(e, opt.value, disabled)}
92+
cursor={disabled ? "not-allowed" : "pointer"}
93+
opacity={disabled ? 0.5 : 1}
94+
borderWidth="1px"
95+
borderRadius="md"
96+
p="2"
97+
transition="all 0.12s ease-in-out"
98+
borderColor={isChecked ? selectedBorder : "gray.200"}
99+
bg={isChecked ? selectedBg : "transparent"}
100+
_hover={{
101+
bg: disabled ? undefined : isChecked ? selectedBg : hoverBg,
102+
}}
103+
_focusVisible={{
104+
boxShadow: "0 0 0 2px var(--chakra-colors-blue-400)",
105+
outline: "none",
106+
}}
107+
>
108+
<Flex align="center" gap="2" opacity={isChecked ? 1 : 0.5}>
109+
{opt.icon ? <Icon as={opt.icon as As} boxSize="16px" /> : null}
110+
<Text fontSize="sm" noOfLines={1}>
111+
{opt.label}
112+
</Text>
113+
</Flex>
114+
</Box>
115+
)
116+
})}
117+
</HStack>
118+
)
119+
}
120+
121+
export default FilterChipsField
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
Avatar,
3+
Box,
4+
Checkbox,
5+
Divider,
6+
Flex,
7+
HStack,
8+
Icon,
9+
Input,
10+
InputGroup,
11+
InputLeftElement,
12+
Text,
13+
} from "@chakra-ui/react"
14+
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
15+
import type * as React from "react"
16+
import { useMemo, useState } from "react"
17+
18+
export type MultiSelectListOption = {
19+
label: string
20+
value: string
21+
avatar?: string
22+
email?: string
23+
isDisabled?: boolean
24+
}
25+
26+
type MultiSelectListFieldProps = {
27+
id?: string
28+
value?: string[]
29+
options: MultiSelectListOption[]
30+
onChange: (values: string[]) => void
31+
maxHeight?: string
32+
isSearchable?: boolean
33+
searchPlaceholder?: string
34+
selectedFirst?: boolean
35+
showSelectedSeparator?: boolean
36+
}
37+
38+
export const MultiSelectListField: React.FC<MultiSelectListFieldProps> = ({
39+
id,
40+
value = [],
41+
options,
42+
onChange,
43+
maxHeight = "300px",
44+
isSearchable = false,
45+
searchPlaceholder = "Search...",
46+
selectedFirst = false,
47+
showSelectedSeparator = false,
48+
}) => {
49+
const safeValue = Array.isArray(value) ? value : []
50+
const [query, setQuery] = useState("")
51+
52+
const toggleValue = (v: string) => {
53+
const set = new Set(safeValue)
54+
if (set.has(v)) {
55+
set.delete(v)
56+
} else {
57+
set.add(v)
58+
}
59+
onChange(Array.from(set))
60+
}
61+
62+
const { selected, other } = useMemo(() => {
63+
const q = query.trim().toLowerCase()
64+
const filtered =
65+
q.length === 0
66+
? options
67+
: options.filter((o) => {
68+
const haystack = `${o.label} ${o.email ?? ""}`.toLowerCase()
69+
return haystack.includes(q)
70+
})
71+
72+
if (!selectedFirst) return { selected: filtered, other: [] }
73+
74+
const selectedOptions: MultiSelectListOption[] = []
75+
const otherOptions: MultiSelectListOption[] = []
76+
const selectedSet = new Set(safeValue)
77+
for (const opt of filtered) {
78+
;(selectedSet.has(opt.value) ? selectedOptions : otherOptions).push(opt)
79+
}
80+
return { selected: selectedOptions, other: otherOptions }
81+
}, [options, query, safeValue, selectedFirst])
82+
83+
const shouldRenderSeparator =
84+
selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0
85+
86+
const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => {
87+
const isChecked = safeValue.includes(opt.value)
88+
const disabled = opt.isDisabled
89+
90+
return (
91+
<HStack
92+
key={opt.value}
93+
px="3"
94+
py="2.5"
95+
spacing="3"
96+
cursor={disabled ? "not-allowed" : "pointer"}
97+
opacity={disabled ? 0.5 : 1}
98+
onClick={() => {
99+
if (!disabled) toggleValue(opt.value)
100+
}}
101+
_hover={{
102+
bg: disabled ? undefined : "gray.50",
103+
}}
104+
borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"}
105+
borderBottomColor="gray.100"
106+
transition="background-color 0.12s ease-in-out"
107+
>
108+
<Checkbox
109+
isChecked={isChecked}
110+
isDisabled={disabled}
111+
onChange={() => {
112+
if (!disabled) toggleValue(opt.value)
113+
}}
114+
pointerEvents="none"
115+
flexShrink={0}
116+
/>
117+
118+
<Avatar
119+
size="xs"
120+
name={opt.label}
121+
src={opt.avatar}
122+
flexShrink={0}
123+
/>
124+
125+
<Flex direction="column" minW={0} flex="1">
126+
<Text fontSize="sm" fontWeight="500" noOfLines={1}>
127+
{opt.label}
128+
</Text>
129+
{opt.email && (
130+
<Text fontSize="xs" color="gray.500" noOfLines={1}>
131+
{opt.email}
132+
</Text>
133+
)}
134+
</Flex>
135+
</HStack>
136+
)
137+
}
138+
139+
const renderedCount = selectedFirst ? selected.length + other.length : selected.length
140+
141+
return (
142+
<Box
143+
id={id}
144+
role="group"
145+
borderWidth="1px"
146+
borderColor="gray.200"
147+
borderRadius="md"
148+
overflow="hidden"
149+
bg="white"
150+
>
151+
{isSearchable ? (
152+
<Box px="3" py="2.5" borderBottomWidth="1px" borderBottomColor="gray.100">
153+
<InputGroup size="sm">
154+
<InputLeftElement pointerEvents="none">
155+
<Icon as={PiMagnifyingGlass} color="gray.400" boxSize={4} />
156+
</InputLeftElement>
157+
<Input
158+
placeholder={searchPlaceholder}
159+
value={query}
160+
onChange={(e) => setQuery(e.target.value)}
161+
bg="gray.50"
162+
borderColor="gray.200"
163+
_hover={{ borderColor: "gray.300" }}
164+
_focus={{
165+
borderColor: "blue.500",
166+
boxShadow: "0 0 0 1px #3182ce",
167+
}}
168+
/>
169+
</InputGroup>
170+
</Box>
171+
) : null}
172+
173+
<Box overflowY="auto" maxH={maxHeight}>
174+
{selectedFirst ? (
175+
<>
176+
{selected.map((opt, idx) => renderOption(opt, idx, selected.length))}
177+
{shouldRenderSeparator ? <Divider borderColor="gray.200" /> : null}
178+
{other.map((opt, idx) => renderOption(opt, idx, other.length))}
179+
</>
180+
) : (
181+
selected.map((opt, idx) => renderOption(opt, idx, selected.length))
182+
)}
183+
184+
{renderedCount === 0 && (
185+
<Flex align="center" justify="center" px="3" py="8" color="gray.500">
186+
<Text fontSize="sm">{query.trim() ? "No matches found" : "No items available"}</Text>
187+
</Flex>
188+
)}
189+
</Box>
190+
</Box>
191+
)
192+
}
193+
194+
export default MultiSelectListField

0 commit comments

Comments
 (0)