Skip to content

Commit aee1dde

Browse files
authored
Merge pull request #340 from softnetics/chayapon/feat/filter-components
refactor: improve enum typesafety for filter component
2 parents dfb7d60 + 3dd0ab4 commit aee1dde

3 files changed

Lines changed: 79 additions & 57 deletions

File tree

.changeset/heavy-pumas-punch.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+
refactor: improve enum typesafety for `Filter` component

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

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

9090
import { PlaygroundCard } from '../../components/card'
9191
import { editorProviderProps } from '../../components/slot-before'
@@ -283,25 +283,33 @@ const mockDataReorder: ReorderMockData[] = [
283283
{ id: 'id5', text: 'Button 5' },
284284
]
285285

286+
const colorOptions = [
287+
{ value: 'red', label: 'Red', isSelected: true },
288+
{ value: 'blue', label: 'Blue', description: 'A calming blue shade' },
289+
{ value: 'green', label: 'Green', description: 'A refreshing green tone' },
290+
]
291+
292+
const animalOptions = [
293+
{ value: 'cat', label: 'Cat', description: 'A small domesticated carnivorous mammal' },
294+
{
295+
value: 'dog',
296+
label: 'Dog',
297+
description: 'A domesticated carnivorous mammal that typically has a long snout',
298+
},
299+
{
300+
value: 'bird',
301+
label: 'Bird',
302+
description: 'A warm-blooded egg-laying vertebrate distinguished by feathers',
303+
},
304+
]
305+
286306
export default function UIPlayground() {
287307
const { setTheme, theme } = useTheme()
288308
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-
])
309+
const [filterOptions, setFilterOptions] = useState({
310+
color: colorOptions,
311+
animal: animalOptions,
312+
})
305313

306314
// Map new order
307315
const handleReorder = (newOrder: string[]) => {
@@ -1711,7 +1719,7 @@ export default function UIPlayground() {
17111719
</Wrapper>
17121720

17131721
<Wrapper title="Filter">
1714-
<Filter items={filterItems} onChange={setFilterItems} />
1722+
<Filter options={filterOptions} onChange={setFilterOptions} />
17151723
</Wrapper>
17161724
</div>
17171725
)

packages/ui/src/components/primitives/filter.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,60 +11,69 @@ import { Typography } from './typography'
1111

1212
import { cn } from '../../utils/cn'
1313

14-
export type FilterItem = {
15-
column: string
16-
value: string
17-
isSelected?: boolean
14+
export type FilterOption<T extends string = string> = {
15+
value: T
1816
label: string
17+
isSelected?: boolean
1918
description?: string
2019
}
2120

22-
interface FilterProps {
23-
items: FilterItem[]
24-
onChange: (items: FilterItem[]) => void
21+
export type FilterOptions = Record<string, FilterOption[]>
22+
23+
export function getSelectedValues<T extends string>(options: FilterOption<T>[]) {
24+
return options.filter((option) => option.isSelected).map((option) => option.value)
2525
}
2626

27-
export function getSelectedValues(column: string, items: FilterItem[]) {
28-
return items.filter((item) => item.column === column && item.isSelected).map((item) => item.value)
27+
export interface FilterProps<T extends FilterOptions = FilterOptions> {
28+
options: T
29+
onChange: (options: T) => void
2930
}
3031

31-
export function Filter({ items, onChange }: FilterProps) {
32+
export function Filter<T extends FilterOptions>({ options, onChange }: FilterProps<T>) {
3233
const [openModal, setOpenModal] = React.useState(false)
33-
const [internalItems, setInternalItems] = React.useState(items)
34+
const [internalOptions, setInternalOptions] = React.useState<T>(options)
3435
const [selectedColumn, setSelectedColumn] = React.useState<string | null>(
35-
items.length > 0 ? items[0].column : null
36+
Object.keys(options)[0] ?? null
3637
)
3738

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)
39+
function toggleItem(column: string, label: string) {
40+
setInternalOptions((prev) => {
41+
const prevOptions = prev[column]
42+
if (!prevOptions) return prev
43+
const newOptions = prevOptions.map((option) => ({
44+
...option,
45+
isSelected: option.label === label ? !option.isSelected : option.isSelected,
46+
}))
47+
return { ...prev, [column]: newOptions }
48+
})
4549
}
4650

4751
function apply() {
48-
onChange(internalItems)
52+
onChange(internalOptions)
4953
setOpenModal(false)
5054
}
5155

5256
function reset() {
53-
const newItems = internalItems.map((item) => ({ ...item, isSelected: false }))
54-
setInternalItems(newItems)
55-
onChange(newItems)
57+
const newOptions = Object.fromEntries(
58+
Object.entries(internalOptions).map(([column, options]) => [
59+
column,
60+
options.map((option) => ({ ...option, isSelected: false })),
61+
])
62+
) as T
63+
setInternalOptions(newOptions)
64+
onChange(newOptions)
5665
setOpenModal(false)
5766
}
5867

5968
function columnCount(column: string) {
60-
return internalItems.reduce(
61-
(acc, item) => acc + (item.column === column && item.isSelected ? 1 : 0),
62-
0
63-
)
69+
return internalOptions[column]?.length || 0
6470
}
6571

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)
72+
const columns = Object.keys(internalOptions)
73+
const totalSelected = Object.entries(options).reduce(
74+
(acc, [_, options]) => acc + options.filter((option) => option.isSelected).length,
75+
0
76+
)
6877

6978
return (
7079
<Popover open={openModal} onOpenChange={setOpenModal}>
@@ -90,34 +99,33 @@ export function Filter({ items, onChange }: FilterProps) {
9099
onClick={() => setSelectedColumn(column)}
91100
>
92101
<Typography weight="normal" type="body">
93-
Columns {column}
102+
{column}
94103
</Typography>
95104
<CountBadge count={columnCount(column)} />
96105
</li>
97106
))}
98107
</ul>
99108

100109
<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) => (
110+
{selectedColumn &&
111+
internalOptions[selectedColumn]?.map((option) => (
104112
<li
105-
key={`${item.column}-${item.value}`}
113+
key={`${selectedColumn}-${option.value}`}
106114
className="w-full flex gap-4 items-start p-4 cursor-pointer"
107-
onClick={() => toggleItem(item.column, item.value)}
115+
onClick={() => toggleItem(selectedColumn, option.label)}
108116
>
109-
<Checkbox checked={item.isSelected} className="mt-1" />
117+
<Checkbox checked={option.isSelected} className="mt-1" />
110118
<div>
111119
<Typography weight="medium" type="body" className="block">
112-
{item.label}
120+
{option.label}
113121
</Typography>
114-
{item.description && (
122+
{option.description && (
115123
<Typography
116124
weight="normal"
117125
type="caption"
118126
className="text-text-secondary block"
119127
>
120-
{item.description}
128+
{option.description}
121129
</Typography>
122130
)}
123131
</div>

0 commit comments

Comments
 (0)