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/fresh-experts-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@example/ui-playground': patch
'@genseki/ui': patch
---

feat: Add Filter components
21 changes: 21 additions & 0 deletions examples/ui-playground/src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
SelectSeparator,
SelectTrigger,
} from '@genseki/react'
import { Filter, type FilterItem } from '@genseki/ui'

import { PlaygroundCard } from '../../components/card'
import { editorProviderProps } from '../../components/slot-before'
Expand Down Expand Up @@ -285,6 +286,22 @@ const mockDataReorder: ReorderMockData[] = [
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',
},
])

// Map new order
const handleReorder = (newOrder: string[]) => {
Expand Down Expand Up @@ -1692,6 +1709,10 @@ export default function UIPlayground() {
</Button>
</form>
</Wrapper>

<Wrapper title="Filter">
<Filter items={filterItems} onChange={setFilterItems} />
</Wrapper>
</div>
)
}
155 changes: 155 additions & 0 deletions packages/ui/src/components/primitives/filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
'use client'

import React from 'react'

import { SlidersHorizontalIcon, TrashIcon } from '@phosphor-icons/react'

import { Button } from './button'
import { Checkbox } from './checkbox'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
import { Typography } from './typography'

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

export type FilterItem = {
column: string
value: string
isSelected?: boolean
label: string
description?: string
}

interface FilterProps {
items: FilterItem[]
onChange: (items: FilterItem[]) => void
}

export function getSelectedValues(column: string, items: FilterItem[]) {
return items.filter((item) => item.column === column && item.isSelected).map((item) => item.value)
}

export function Filter({ items, onChange }: FilterProps) {
const [openModal, setOpenModal] = React.useState(false)
const [internalItems, setInternalItems] = React.useState(items)
const [selectedColumn, setSelectedColumn] = React.useState<string | null>(
items.length > 0 ? items[0].column : 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 apply() {
onChange(internalItems)
setOpenModal(false)
}

function reset() {
const newItems = internalItems.map((item) => ({ ...item, isSelected: false }))
setInternalItems(newItems)
onChange(newItems)
setOpenModal(false)
}

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

const columns = Array.from(new Set(internalItems.map((item) => item.column)))
const totalSelected = internalItems.reduce((acc, item) => acc + (item.isSelected ? 1 : 0), 0)

return (
<Popover open={openModal} onOpenChange={setOpenModal}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-fit">
<Typography>Filter</Typography>
<CountBadge count={totalSelected} />
<SlidersHorizontalIcon />
</Button>
</PopoverTrigger>
<PopoverContent asChild>
<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]">
<Typography type="h4" weight="bold" className="px-6 text-lg w-full">
Apply Filters
</Typography>

<div className="flex items-start gap-6 w-full px-6 flex-1 min-h-0">
<ul className="min-w-[230px] overflow-auto h-full">
{columns.map((column) => (
<li
key={column}
className="w-full p-4 rounded-sm hover:bg-surface-primary-hover flex items-center cursor-pointer justify-between"
onClick={() => setSelectedColumn(column)}
>
<Typography weight="normal" type="body">
Columns {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) => (
<li
key={`${item.column}-${item.value}`}
className="w-full flex gap-4 items-start p-4 cursor-pointer"
onClick={() => toggleItem(item.column, item.value)}
>
<Checkbox checked={item.isSelected} className="mt-1" />
<div>
<Typography weight="medium" type="body" className="block">
{item.label}
</Typography>
{item.description && (
<Typography
weight="normal"
type="caption"
className="text-text-secondary block"
>
{item.description}
</Typography>
)}
</div>
</li>
))}
</ul>
</div>

<div className="w-full px-6 pt-4 border-t flex items-center justify-end gap-2 border-border-primary">
<Button variant="outline" onClick={reset}>
<TrashIcon />
<Typography>Reset All</Typography>
</Button>
<Button onClick={apply}>Apply</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

function CountBadge({ count }: { count: number }) {
return (
<div
className={cn(
'size-[22px] rounded-full bg-surface-primary border flex items-center justify-center',
{
'opacity-0': count === 0,
}
)}
>
{count}
</div>
)
}
1 change: 1 addition & 0 deletions packages/ui/src/components/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './dialog'
export * from './drop-zone'
export * from './dropdown-menu'
export * from './file-preview'
export * from './filter'
export * from './input'
export * from './input-group'
export * from './input-otp'
Expand Down
Loading