Skip to content

Commit 0a9ceb4

Browse files
authored
Merge pull request #338 from softnetics/chayapon/feat/filter-components
feat: filter component
2 parents 3ba6cae + c56e756 commit 0a9ceb4

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

.changeset/fresh-experts-cheer.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@example/ui-playground': patch
3+
'@genseki/ui': patch
4+
---
5+
6+
feat: Add Filter components

examples/ui-playground/src/app/playground/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
SelectSeparator,
8686
SelectTrigger,
8787
} from '@genseki/react'
88+
import { Filter, type FilterItem } from '@genseki/ui'
8889

8990
import { PlaygroundCard } from '../../components/card'
9091
import { editorProviderProps } from '../../components/slot-before'
@@ -285,6 +286,22 @@ const mockDataReorder: ReorderMockData[] = [
285286
export default function UIPlayground() {
286287
const { setTheme, theme } = useTheme()
287288
const [btnData, setBtnData] = useState<ReorderMockData[]>(mockDataReorder)
289+
const [filterItems, setFilterItems] = useState<FilterItem[]>([
290+
{ column: 'color', value: 'red', label: 'Red' },
291+
{ column: 'color', value: 'blue', label: 'Blue', description: 'A calming blue shade' },
292+
{
293+
column: 'animal',
294+
value: 'cat',
295+
label: 'Cat',
296+
description: 'A small domesticated carnivorous mammal',
297+
},
298+
{
299+
column: 'animal',
300+
value: 'dog',
301+
label: 'Dog',
302+
description: 'A domesticated carnivorous mammal that typically has a long snout',
303+
},
304+
])
288305

289306
// Map new order
290307
const handleReorder = (newOrder: string[]) => {
@@ -1692,6 +1709,10 @@ export default function UIPlayground() {
16921709
</Button>
16931710
</form>
16941711
</Wrapper>
1712+
1713+
<Wrapper title="Filter">
1714+
<Filter items={filterItems} onChange={setFilterItems} />
1715+
</Wrapper>
16951716
</div>
16961717
)
16971718
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
import { SlidersHorizontalIcon, TrashIcon } from '@phosphor-icons/react'
6+
7+
import { Button } from './button'
8+
import { Checkbox } from './checkbox'
9+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
10+
import { Typography } from './typography'
11+
12+
import { cn } from '../../utils/cn'
13+
14+
export type FilterItem = {
15+
column: string
16+
value: string
17+
isSelected?: boolean
18+
label: string
19+
description?: string
20+
}
21+
22+
interface FilterProps {
23+
items: FilterItem[]
24+
onChange: (items: FilterItem[]) => void
25+
}
26+
27+
export function getSelectedValues(column: string, items: FilterItem[]) {
28+
return items.filter((item) => item.column === column && item.isSelected).map((item) => item.value)
29+
}
30+
31+
export function Filter({ items, onChange }: FilterProps) {
32+
const [openModal, setOpenModal] = React.useState(false)
33+
const [internalItems, setInternalItems] = React.useState(items)
34+
const [selectedColumn, setSelectedColumn] = React.useState<string | null>(
35+
items.length > 0 ? items[0].column : null
36+
)
37+
38+
function toggleItem(column: string, value: string) {
39+
const newItems = internalItems.map((item) => ({
40+
...item,
41+
isSelected:
42+
item.column === column && item.value === value ? !item.isSelected : item.isSelected,
43+
}))
44+
setInternalItems(newItems)
45+
}
46+
47+
function apply() {
48+
onChange(internalItems)
49+
setOpenModal(false)
50+
}
51+
52+
function reset() {
53+
const newItems = internalItems.map((item) => ({ ...item, isSelected: false }))
54+
setInternalItems(newItems)
55+
onChange(newItems)
56+
setOpenModal(false)
57+
}
58+
59+
function columnCount(column: string) {
60+
return internalItems.reduce(
61+
(acc, item) => acc + (item.column === column && item.isSelected ? 1 : 0),
62+
0
63+
)
64+
}
65+
66+
const columns = Array.from(new Set(internalItems.map((item) => item.column)))
67+
const totalSelected = internalItems.reduce((acc, item) => acc + (item.isSelected ? 1 : 0), 0)
68+
69+
return (
70+
<Popover open={openModal} onOpenChange={setOpenModal}>
71+
<PopoverTrigger asChild>
72+
<Button variant="outline" className="w-fit">
73+
<Typography>Filter</Typography>
74+
<CountBadge count={totalSelected} />
75+
<SlidersHorizontalIcon />
76+
</Button>
77+
</PopoverTrigger>
78+
<PopoverContent asChild>
79+
<div className="w-fit py-6 bg-surface-primary border border-border-primary rounded-xl flex flex-col gap-4 min-w-[600px] h-[436px]">
80+
<Typography type="h4" weight="bold" className="px-6 text-lg w-full">
81+
Apply Filters
82+
</Typography>
83+
84+
<div className="flex items-start gap-6 w-full px-6 flex-1 min-h-0">
85+
<ul className="min-w-[230px] overflow-auto h-full">
86+
{columns.map((column) => (
87+
<li
88+
key={column}
89+
className="w-full p-4 rounded-sm hover:bg-surface-primary-hover flex items-center cursor-pointer justify-between"
90+
onClick={() => setSelectedColumn(column)}
91+
>
92+
<Typography weight="normal" type="body">
93+
Columns {column}
94+
</Typography>
95+
<CountBadge count={columnCount(column)} />
96+
</li>
97+
))}
98+
</ul>
99+
100+
<ul className="flex-1 h-full border border-border-primary p-3 rounded-lg overflow-auto">
101+
{internalItems
102+
.filter((item) => item.column === selectedColumn)
103+
.map((item) => (
104+
<li
105+
key={`${item.column}-${item.value}`}
106+
className="w-full flex gap-4 items-start p-4 cursor-pointer"
107+
onClick={() => toggleItem(item.column, item.value)}
108+
>
109+
<Checkbox checked={item.isSelected} className="mt-1" />
110+
<div>
111+
<Typography weight="medium" type="body" className="block">
112+
{item.label}
113+
</Typography>
114+
{item.description && (
115+
<Typography
116+
weight="normal"
117+
type="caption"
118+
className="text-text-secondary block"
119+
>
120+
{item.description}
121+
</Typography>
122+
)}
123+
</div>
124+
</li>
125+
))}
126+
</ul>
127+
</div>
128+
129+
<div className="w-full px-6 pt-4 border-t flex items-center justify-end gap-2 border-border-primary">
130+
<Button variant="outline" onClick={reset}>
131+
<TrashIcon />
132+
<Typography>Reset All</Typography>
133+
</Button>
134+
<Button onClick={apply}>Apply</Button>
135+
</div>
136+
</div>
137+
</PopoverContent>
138+
</Popover>
139+
)
140+
}
141+
142+
function CountBadge({ count }: { count: number }) {
143+
return (
144+
<div
145+
className={cn(
146+
'size-[22px] rounded-full bg-surface-primary border flex items-center justify-center',
147+
{
148+
'opacity-0': count === 0,
149+
}
150+
)}
151+
>
152+
{count}
153+
</div>
154+
)
155+
}

packages/ui/src/components/primitives/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './dialog'
1414
export * from './drop-zone'
1515
export * from './dropdown-menu'
1616
export * from './file-preview'
17+
export * from './filter'
1718
export * from './input'
1819
export * from './input-group'
1920
export * from './input-otp'

0 commit comments

Comments
 (0)