Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/heavy-pumas-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@example/ui-playground': patch
'@genseki/ui': patch
---

refactor: improve enum typesafety for `Filter` component
44 changes: 26 additions & 18 deletions examples/ui-playground/src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
SelectSeparator,
SelectTrigger,
} from '@genseki/react'
import { Filter, type FilterItem } from '@genseki/ui'
import { Filter } from '@genseki/ui'

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

const colorOptions = [
{ value: 'red', label: 'Red', isSelected: true },
{ value: 'blue', label: 'Blue', description: 'A calming blue shade' },
{ value: 'green', label: 'Green', description: 'A refreshing green tone' },
]

const animalOptions = [
{ value: 'cat', label: 'Cat', description: 'A small domesticated carnivorous mammal' },
{
value: 'dog',
label: 'Dog',
description: 'A domesticated carnivorous mammal that typically has a long snout',
},
{
value: 'bird',
label: 'Bird',
description: 'A warm-blooded egg-laying vertebrate distinguished by feathers',
},
]

export default function UIPlayground() {
const { setTheme, theme } = useTheme()
const [btnData, setBtnData] = useState<ReorderMockData[]>(mockDataReorder)
const [filterItems, setFilterItems] = useState<FilterItem[]>([
{ column: 'color', value: 'red', label: 'Red' },
{ column: 'color', value: 'blue', label: 'Blue', description: 'A calming blue shade' },
{
column: 'animal',
value: 'cat',
label: 'Cat',
description: 'A small domesticated carnivorous mammal',
},
{
column: 'animal',
value: 'dog',
label: 'Dog',
description: 'A domesticated carnivorous mammal that typically has a long snout',
},
])
const [filterOptions, setFilterOptions] = useState({
color: colorOptions,
animal: animalOptions,
})

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

<Wrapper title="Filter">
<Filter items={filterItems} onChange={setFilterItems} />
<Filter options={filterOptions} onChange={setFilterOptions} />
</Wrapper>
</div>
)
Expand Down
86 changes: 47 additions & 39 deletions packages/ui/src/components/primitives/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,60 +11,69 @@ import { Typography } from './typography'

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

export type FilterItem = {
column: string
value: string
isSelected?: boolean
export type FilterOption<T extends string = string> = {
value: T
label: string
isSelected?: boolean
description?: string
}

interface FilterProps {
items: FilterItem[]
onChange: (items: FilterItem[]) => void
export type FilterOptions = Record<string, FilterOption[]>

export function getSelectedValues<T extends string>(options: FilterOption<T>[]) {
return options.filter((option) => option.isSelected).map((option) => option.value)
}

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

export function Filter({ items, onChange }: FilterProps) {
export function Filter<T extends FilterOptions>({ options, onChange }: FilterProps<T>) {
const [openModal, setOpenModal] = React.useState(false)
const [internalItems, setInternalItems] = React.useState(items)
const [internalOptions, setInternalOptions] = React.useState<T>(options)
const [selectedColumn, setSelectedColumn] = React.useState<string | null>(
items.length > 0 ? items[0].column : null
Object.keys(options)[0] ?? null
)

function toggleItem(column: string, value: string) {
const newItems = internalItems.map((item) => ({
...item,
isSelected:
item.column === column && item.value === value ? !item.isSelected : item.isSelected,
}))
setInternalItems(newItems)
function toggleItem(column: string, label: string) {
setInternalOptions((prev) => {
const prevOptions = prev[column]
if (!prevOptions) return prev
const newOptions = prevOptions.map((option) => ({
...option,
isSelected: option.label === label ? !option.isSelected : option.isSelected,
}))
return { ...prev, [column]: newOptions }
})
}

function apply() {
onChange(internalItems)
onChange(internalOptions)
setOpenModal(false)
}

function reset() {
const newItems = internalItems.map((item) => ({ ...item, isSelected: false }))
setInternalItems(newItems)
onChange(newItems)
const newOptions = Object.fromEntries(
Object.entries(internalOptions).map(([column, options]) => [
column,
options.map((option) => ({ ...option, isSelected: false })),
])
) as T
setInternalOptions(newOptions)
onChange(newOptions)
setOpenModal(false)
}

function columnCount(column: string) {
return internalItems.reduce(
(acc, item) => acc + (item.column === column && item.isSelected ? 1 : 0),
0
)
return internalOptions[column]?.length || 0
}

const columns = Array.from(new Set(internalItems.map((item) => item.column)))
const totalSelected = internalItems.reduce((acc, item) => acc + (item.isSelected ? 1 : 0), 0)
const columns = Object.keys(internalOptions)
const totalSelected = Object.entries(options).reduce(
(acc, [_, options]) => acc + options.filter((option) => option.isSelected).length,
0
)

return (
<Popover open={openModal} onOpenChange={setOpenModal}>
Expand All @@ -90,34 +99,33 @@ export function Filter({ items, onChange }: FilterProps) {
onClick={() => setSelectedColumn(column)}
>
<Typography weight="normal" type="body">
Columns {column}
{column}
</Typography>
<CountBadge count={columnCount(column)} />
</li>
))}
</ul>

<ul className="flex-1 h-full border border-border-primary p-3 rounded-lg overflow-auto">
{internalItems
.filter((item) => item.column === selectedColumn)
.map((item) => (
{selectedColumn &&
internalOptions[selectedColumn]?.map((option) => (
<li
key={`${item.column}-${item.value}`}
key={`${selectedColumn}-${option.value}`}
className="w-full flex gap-4 items-start p-4 cursor-pointer"
onClick={() => toggleItem(item.column, item.value)}
onClick={() => toggleItem(selectedColumn, option.label)}
>
<Checkbox checked={item.isSelected} className="mt-1" />
<Checkbox checked={option.isSelected} className="mt-1" />
<div>
<Typography weight="medium" type="body" className="block">
{item.label}
{option.label}
</Typography>
{item.description && (
{option.description && (
<Typography
weight="normal"
type="caption"
className="text-text-secondary block"
>
{item.description}
{option.description}
</Typography>
)}
</div>
Expand Down
Loading