minka-ds 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/components/ui/calendar.tsx +188 -0
- package/src/components/ui/data-table.tsx +31 -94
- package/src/components/ui/filter-chip.tsx +72 -0
- package/src/components/ui/filter-combobox.tsx +430 -0
- package/src/components/ui/kbd.tsx +15 -0
- package/src/components/ui/search-bar.tsx +179 -0
- package/src/hooks/use-platform.ts +11 -0
- package/src/index.ts +9 -1
- package/tokens/primitives.css +4 -3
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minka-ds",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Minka product design system — tokenized component library",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"files": [
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"tokens"
|
|
9
|
+
],
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./src/index.ts",
|
|
9
12
|
"./tokens/*": "./tokens/*"
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
"clsx": "^2.1.1",
|
|
20
23
|
"lucide-react": "^1.14.0",
|
|
21
24
|
"radix-ui": "^1.4.3",
|
|
25
|
+
"react-day-picker": "^10.0.0",
|
|
22
26
|
"tailwind-merge": "^3.5.0",
|
|
23
27
|
"tw-animate-css": "^1.4.0"
|
|
24
28
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
ChevronDownIcon,
|
|
6
|
+
ChevronLeftIcon,
|
|
7
|
+
ChevronRightIcon,
|
|
8
|
+
} from "lucide-react"
|
|
9
|
+
import {
|
|
10
|
+
DayPicker,
|
|
11
|
+
getDefaultClassNames,
|
|
12
|
+
type DayButton,
|
|
13
|
+
} from "react-day-picker"
|
|
14
|
+
|
|
15
|
+
import { cn } from "../../lib/utils"
|
|
16
|
+
import { Button, buttonVariants } from "./button"
|
|
17
|
+
|
|
18
|
+
function Calendar({
|
|
19
|
+
className,
|
|
20
|
+
classNames,
|
|
21
|
+
showOutsideDays = true,
|
|
22
|
+
captionLayout = "dropdown",
|
|
23
|
+
numberOfMonths,
|
|
24
|
+
formatters,
|
|
25
|
+
components,
|
|
26
|
+
...props
|
|
27
|
+
}: React.ComponentProps<typeof DayPicker> & {
|
|
28
|
+
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
|
29
|
+
}) {
|
|
30
|
+
const defaultClassNames = getDefaultClassNames()
|
|
31
|
+
const resolvedMonths = numberOfMonths ?? (props.mode === "range" ? 2 : 1)
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<DayPicker
|
|
35
|
+
showOutsideDays={showOutsideDays}
|
|
36
|
+
captionLayout={captionLayout}
|
|
37
|
+
numberOfMonths={resolvedMonths}
|
|
38
|
+
className={cn(
|
|
39
|
+
"group/calendar bg-[var(--color-bg-raised)] rounded-[var(--radius-card)] p-3 [--cell-size:--spacing(9)]",
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
formatters={{
|
|
43
|
+
formatMonthDropdown: (date) =>
|
|
44
|
+
date.toLocaleString("default", { month: "short" }),
|
|
45
|
+
...formatters,
|
|
46
|
+
}}
|
|
47
|
+
classNames={{
|
|
48
|
+
root: cn("w-fit", defaultClassNames.root),
|
|
49
|
+
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
|
|
50
|
+
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
|
51
|
+
|
|
52
|
+
// ── Nav ─────────────────────────────────────────────────────────────
|
|
53
|
+
nav: cn(
|
|
54
|
+
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
|
55
|
+
defaultClassNames.nav
|
|
56
|
+
),
|
|
57
|
+
button_previous: cn(
|
|
58
|
+
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
|
59
|
+
"size-(--cell-size) select-none aria-disabled:opacity-40",
|
|
60
|
+
defaultClassNames.button_previous
|
|
61
|
+
),
|
|
62
|
+
button_next: cn(
|
|
63
|
+
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
|
64
|
+
"size-(--cell-size) select-none aria-disabled:opacity-40",
|
|
65
|
+
defaultClassNames.button_next
|
|
66
|
+
),
|
|
67
|
+
|
|
68
|
+
// ── Caption ──────────────────────────────────────────────────────────
|
|
69
|
+
month_caption: cn(
|
|
70
|
+
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
|
71
|
+
defaultClassNames.month_caption
|
|
72
|
+
),
|
|
73
|
+
caption_label: cn(
|
|
74
|
+
"font-medium select-none",
|
|
75
|
+
captionLayout === "label"
|
|
76
|
+
? "text-body-sm"
|
|
77
|
+
: "flex h-8 items-center gap-1 [border-radius:var(--radius-input)] pr-1 pl-2 text-body-sm hover:bg-[var(--color-action-ghost-hover)] transition-colors [&>svg]:size-3.5 [&>svg]:text-[var(--color-text-muted)]",
|
|
78
|
+
defaultClassNames.caption_label
|
|
79
|
+
),
|
|
80
|
+
|
|
81
|
+
// ── Dropdowns ────────────────────────────────────────────────────────
|
|
82
|
+
dropdowns: cn(
|
|
83
|
+
"flex h-(--cell-size) w-full items-center justify-center gap-1.5",
|
|
84
|
+
defaultClassNames.dropdowns
|
|
85
|
+
),
|
|
86
|
+
dropdown_root: cn(
|
|
87
|
+
"relative [border-radius:var(--radius-button)] hover:bg-[var(--color-action-ghost-hover)] transition-colors",
|
|
88
|
+
defaultClassNames.dropdown_root
|
|
89
|
+
),
|
|
90
|
+
dropdown: cn(
|
|
91
|
+
"absolute inset-0 opacity-0 cursor-pointer",
|
|
92
|
+
defaultClassNames.dropdown
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
// ── Grid ─────────────────────────────────────────────────────────────
|
|
96
|
+
month_grid: "w-full border-collapse",
|
|
97
|
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
98
|
+
weekday: cn(
|
|
99
|
+
"flex-1 text-caption text-[var(--color-text-muted)] text-center select-none",
|
|
100
|
+
defaultClassNames.weekday
|
|
101
|
+
),
|
|
102
|
+
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
|
103
|
+
|
|
104
|
+
// ── Day cells ────────────────────────────────────────────────────────
|
|
105
|
+
day: cn(
|
|
106
|
+
"group/day relative aspect-square h-full w-full p-0 text-center select-none",
|
|
107
|
+
"[&:last-child[data-selected=true]_button]:rounded-r-[var(--radius-input)]",
|
|
108
|
+
props.showWeekNumber
|
|
109
|
+
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-[var(--radius-input)]"
|
|
110
|
+
: "[&:first-child[data-selected=true]_button]:rounded-l-[var(--radius-input)]",
|
|
111
|
+
defaultClassNames.day
|
|
112
|
+
),
|
|
113
|
+
range_start: cn("rounded-l-[var(--radius-input)] bg-[var(--color-action-ghost-hover)]", defaultClassNames.range_start),
|
|
114
|
+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
|
115
|
+
range_end: cn("rounded-r-[var(--radius-input)] bg-[var(--color-action-ghost-hover)]", defaultClassNames.range_end),
|
|
116
|
+
today: cn(
|
|
117
|
+
"rounded-[var(--radius-input)] bg-[var(--color-action-ghost-hover)] text-[var(--color-text-default)] data-[selected=true]:rounded-none",
|
|
118
|
+
defaultClassNames.today
|
|
119
|
+
),
|
|
120
|
+
outside: cn("text-[var(--color-text-hint)] aria-selected:text-[var(--color-text-hint)]", defaultClassNames.outside),
|
|
121
|
+
disabled: cn("text-[var(--color-text-disabled)] opacity-50", defaultClassNames.disabled),
|
|
122
|
+
hidden: cn("invisible", defaultClassNames.hidden),
|
|
123
|
+
...classNames,
|
|
124
|
+
}}
|
|
125
|
+
components={{
|
|
126
|
+
Root: ({ className, rootRef, ...props }) => (
|
|
127
|
+
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
|
128
|
+
),
|
|
129
|
+
Chevron: ({ className, orientation, ...props }) => {
|
|
130
|
+
if (orientation === "left") return <ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
|
131
|
+
if (orientation === "right") return <ChevronRightIcon className={cn("size-4", className)} {...props} />
|
|
132
|
+
return <ChevronDownIcon className={cn("size-4", className)} {...props} />
|
|
133
|
+
},
|
|
134
|
+
DayButton: CalendarDayButton,
|
|
135
|
+
...components,
|
|
136
|
+
}}
|
|
137
|
+
{...props}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function CalendarDayButton({
|
|
143
|
+
className,
|
|
144
|
+
day,
|
|
145
|
+
modifiers,
|
|
146
|
+
...props
|
|
147
|
+
}: React.ComponentProps<typeof DayButton>) {
|
|
148
|
+
const defaultClassNames = getDefaultClassNames()
|
|
149
|
+
const ref = React.useRef<HTMLButtonElement>(null)
|
|
150
|
+
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
if (modifiers.focused) ref.current?.focus()
|
|
153
|
+
}, [modifiers.focused])
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Button
|
|
157
|
+
ref={ref}
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon"
|
|
160
|
+
data-day={day.date.toLocaleDateString()}
|
|
161
|
+
data-selected-single={
|
|
162
|
+
modifiers.selected &&
|
|
163
|
+
!modifiers.range_start &&
|
|
164
|
+
!modifiers.range_end &&
|
|
165
|
+
!modifiers.range_middle
|
|
166
|
+
}
|
|
167
|
+
data-range-start={modifiers.range_start}
|
|
168
|
+
data-range-end={modifiers.range_end}
|
|
169
|
+
data-range-middle={modifiers.range_middle}
|
|
170
|
+
className={cn(
|
|
171
|
+
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none",
|
|
172
|
+
modifiers.selected || modifiers.today ? "text-body-sm" : "text-body-sm-light",
|
|
173
|
+
modifiers.outside && "text-[var(--color-text-hint)] hover:text-[var(--color-text-hint)]",
|
|
174
|
+
"group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10",
|
|
175
|
+
"group-data-[focused=true]/day:border-[var(--color-border-focus)] group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-[var(--color-border-focus)]/50",
|
|
176
|
+
"data-[selected-single=true]:bg-[var(--color-action-primary-default)] data-[selected-single=true]:text-[var(--color-action-primary-foreground)]",
|
|
177
|
+
"data-[range-start=true]:rounded-l-[var(--radius-input)] data-[range-start=true]:bg-[var(--color-action-primary-default)] data-[range-start=true]:text-[var(--color-action-primary-foreground)]",
|
|
178
|
+
"data-[range-end=true]:rounded-r-[var(--radius-input)] data-[range-end=true]:bg-[var(--color-action-primary-default)] data-[range-end=true]:text-[var(--color-action-primary-foreground)]",
|
|
179
|
+
"data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-[var(--color-action-ghost-hover)] data-[range-middle=true]:text-[var(--color-text-default)]",
|
|
180
|
+
defaultClassNames.day,
|
|
181
|
+
className
|
|
182
|
+
)}
|
|
183
|
+
{...props}
|
|
184
|
+
/>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { Calendar, CalendarDayButton }
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
VisibilityState,
|
|
8
8
|
flexRender,
|
|
9
9
|
getCoreRowModel,
|
|
10
|
-
getPaginationRowModel,
|
|
11
10
|
getSortedRowModel,
|
|
12
11
|
useReactTable,
|
|
13
12
|
type Table as TanstackTable,
|
|
@@ -16,15 +15,6 @@ import { ChevronsUpDown, ChevronUp, ChevronDown, Columns3Cog } from "lucide-reac
|
|
|
16
15
|
|
|
17
16
|
import { cn } from "../../lib/utils"
|
|
18
17
|
import { Button } from "./button"
|
|
19
|
-
import {
|
|
20
|
-
Pagination,
|
|
21
|
-
PaginationContent,
|
|
22
|
-
PaginationEllipsis,
|
|
23
|
-
PaginationItem,
|
|
24
|
-
PaginationLink,
|
|
25
|
-
PaginationNext,
|
|
26
|
-
PaginationPrevious,
|
|
27
|
-
} from "./pagination"
|
|
28
18
|
import {
|
|
29
19
|
DropdownMenu,
|
|
30
20
|
DropdownMenuCheckboxItem,
|
|
@@ -44,12 +34,6 @@ import {
|
|
|
44
34
|
|
|
45
35
|
// ── Column header with sort control ──────────────────────────────────────────
|
|
46
36
|
|
|
47
|
-
interface DataTableColumnHeaderProps<TData, TValue>
|
|
48
|
-
extends React.HTMLAttributes<HTMLDivElement> {
|
|
49
|
-
column: TanstackTable<TData>["getColumn"] extends (id: string) => infer C ? NonNullable<C> : never
|
|
50
|
-
title: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
37
|
function DataTableColumnHeader<TData, TValue>({
|
|
54
38
|
column,
|
|
55
39
|
title,
|
|
@@ -117,108 +101,61 @@ function DataTableColumnToggle<TData>({
|
|
|
117
101
|
)
|
|
118
102
|
}
|
|
119
103
|
|
|
120
|
-
// ── Pagination ────────────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
function getPageNumbers(currentPage: number, totalPages: number): (number | "ellipsis")[] {
|
|
123
|
-
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
124
|
-
const pages: (number | "ellipsis")[] = [1]
|
|
125
|
-
if (currentPage > 3) pages.push("ellipsis")
|
|
126
|
-
const start = Math.max(2, currentPage - 1)
|
|
127
|
-
const end = Math.min(totalPages - 1, currentPage + 1)
|
|
128
|
-
for (let i = start; i <= end; i++) pages.push(i)
|
|
129
|
-
if (currentPage < totalPages - 2) pages.push("ellipsis")
|
|
130
|
-
pages.push(totalPages)
|
|
131
|
-
return pages
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function DataTablePagination<TData>({
|
|
135
|
-
table,
|
|
136
|
-
}: {
|
|
137
|
-
table: TanstackTable<TData>
|
|
138
|
-
}) {
|
|
139
|
-
const currentPage = table.getState().pagination.pageIndex + 1
|
|
140
|
-
const totalPages = table.getPageCount()
|
|
141
|
-
const pages = getPageNumbers(currentPage, totalPages)
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<div className="flex items-center justify-between">
|
|
145
|
-
<p className="text-body-sm text-[var(--color-text-muted)]">
|
|
146
|
-
Page {currentPage} of {totalPages}
|
|
147
|
-
</p>
|
|
148
|
-
<Pagination className="mx-0 w-auto justify-end">
|
|
149
|
-
<PaginationContent>
|
|
150
|
-
<PaginationItem>
|
|
151
|
-
<PaginationPrevious
|
|
152
|
-
onClick={() => table.previousPage()}
|
|
153
|
-
aria-disabled={!table.getCanPreviousPage()}
|
|
154
|
-
className={cn(!table.getCanPreviousPage() && "pointer-events-none opacity-50")}
|
|
155
|
-
/>
|
|
156
|
-
</PaginationItem>
|
|
157
|
-
{pages.map((page, i) =>
|
|
158
|
-
page === "ellipsis" ? (
|
|
159
|
-
<PaginationItem key={`ellipsis-${i}`}>
|
|
160
|
-
<PaginationEllipsis />
|
|
161
|
-
</PaginationItem>
|
|
162
|
-
) : (
|
|
163
|
-
<PaginationItem key={page}>
|
|
164
|
-
<PaginationLink
|
|
165
|
-
isActive={page === currentPage}
|
|
166
|
-
onClick={() => table.setPageIndex(page - 1)}
|
|
167
|
-
className="cursor-pointer"
|
|
168
|
-
>
|
|
169
|
-
{page}
|
|
170
|
-
</PaginationLink>
|
|
171
|
-
</PaginationItem>
|
|
172
|
-
)
|
|
173
|
-
)}
|
|
174
|
-
<PaginationItem>
|
|
175
|
-
<PaginationNext
|
|
176
|
-
onClick={() => table.nextPage()}
|
|
177
|
-
aria-disabled={!table.getCanNextPage()}
|
|
178
|
-
className={cn(!table.getCanNextPage() && "pointer-events-none opacity-50")}
|
|
179
|
-
/>
|
|
180
|
-
</PaginationItem>
|
|
181
|
-
</PaginationContent>
|
|
182
|
-
</Pagination>
|
|
183
|
-
</div>
|
|
184
|
-
)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
104
|
// ── DataTable ─────────────────────────────────────────────────────────────────
|
|
188
105
|
|
|
189
106
|
interface DataTableProps<TData, TValue> {
|
|
190
107
|
columns: ColumnDef<TData, TValue>[]
|
|
191
108
|
data: TData[]
|
|
192
|
-
|
|
109
|
+
batchSize?: number
|
|
193
110
|
onRowClick?: (row: TData) => void
|
|
111
|
+
className?: string
|
|
194
112
|
}
|
|
195
113
|
|
|
196
114
|
function DataTable<TData, TValue>({
|
|
197
115
|
columns,
|
|
198
116
|
data,
|
|
199
|
-
|
|
117
|
+
batchSize = 20,
|
|
200
118
|
onRowClick,
|
|
119
|
+
className,
|
|
201
120
|
}: DataTableProps<TData, TValue>) {
|
|
202
121
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
|
203
122
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
|
123
|
+
const [displayCount, setDisplayCount] = React.useState(batchSize)
|
|
124
|
+
const [hasMore, setHasMore] = React.useState(data.length > batchSize)
|
|
125
|
+
|
|
126
|
+
const displayedData = React.useMemo(
|
|
127
|
+
() => data.slice(0, displayCount),
|
|
128
|
+
[data, displayCount]
|
|
129
|
+
)
|
|
204
130
|
|
|
205
131
|
const table = useReactTable({
|
|
206
|
-
data,
|
|
132
|
+
data: displayedData,
|
|
207
133
|
columns,
|
|
208
134
|
getCoreRowModel: getCoreRowModel(),
|
|
209
|
-
getPaginationRowModel: getPaginationRowModel(),
|
|
210
135
|
getSortedRowModel: getSortedRowModel(),
|
|
211
136
|
onSortingChange: setSorting,
|
|
212
137
|
onColumnVisibilityChange: setColumnVisibility,
|
|
213
|
-
initialState: { pagination: { pageSize } },
|
|
214
138
|
state: { sorting, columnVisibility },
|
|
215
139
|
})
|
|
216
140
|
|
|
141
|
+
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
|
|
142
|
+
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
|
143
|
+
const remaining = scrollHeight - scrollTop - clientHeight
|
|
144
|
+
setHasMore(remaining > 2 || displayCount < data.length)
|
|
145
|
+
if (remaining < 120) {
|
|
146
|
+
setDisplayCount((c) => Math.min(c + batchSize, data.length))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
217
150
|
return (
|
|
218
|
-
<div className="
|
|
219
|
-
<div
|
|
151
|
+
<div className={cn("relative flex flex-col min-h-0", className)}>
|
|
152
|
+
<div
|
|
153
|
+
onScroll={handleScroll}
|
|
154
|
+
className="flex-1 min-h-0 overflow-auto rounded-[var(--radius-card)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] [&_[data-slot=table-container]]:overflow-visible"
|
|
155
|
+
>
|
|
156
|
+
|
|
220
157
|
<Table className="[&_th:first-child]:pl-4 [&_td:first-child]:pl-4">
|
|
221
|
-
<TableHeader className="bg-[var(--color-bg-base)]">
|
|
158
|
+
<TableHeader className="sticky top-0 [z-index:var(--z-sticky)] bg-[var(--color-bg-base)]">
|
|
222
159
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
223
160
|
<TableRow key={headerGroup.id}>
|
|
224
161
|
{headerGroup.headers.map((header, index) => (
|
|
@@ -265,15 +202,15 @@ function DataTable<TData, TValue>({
|
|
|
265
202
|
</TableBody>
|
|
266
203
|
</Table>
|
|
267
204
|
</div>
|
|
268
|
-
|
|
205
|
+
{hasMore && (
|
|
206
|
+
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 rounded-b-[var(--radius-card)] bg-gradient-to-t from-[var(--color-bg-raised)] to-transparent" />
|
|
207
|
+
)}
|
|
269
208
|
</div>
|
|
270
209
|
)
|
|
271
|
-
|
|
272
210
|
}
|
|
273
211
|
|
|
274
212
|
export {
|
|
275
213
|
DataTable,
|
|
276
214
|
DataTableColumnHeader,
|
|
277
215
|
DataTableColumnToggle,
|
|
278
|
-
DataTablePagination,
|
|
279
216
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { XIcon } from "lucide-react"
|
|
5
|
+
import { cn } from "../../lib/utils"
|
|
6
|
+
|
|
7
|
+
type FilterChipProps =
|
|
8
|
+
| {
|
|
9
|
+
variant?: "filter"
|
|
10
|
+
label: string
|
|
11
|
+
values: { label: string; onRemove: () => void }[]
|
|
12
|
+
onLabelClick?: () => void
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
variant: "clear-all"
|
|
17
|
+
onClear: () => void
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function FilterChip(props: FilterChipProps) {
|
|
22
|
+
if (props.variant === "clear-all") {
|
|
23
|
+
const { onClear, className } = props
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={onClear}
|
|
28
|
+
className={cn(
|
|
29
|
+
"text-caption text-[var(--color-text-muted)] underline-offset-2 hover:underline cursor-pointer",
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
Clear all
|
|
34
|
+
</button>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { label, values, onLabelClick, className } = props
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn("flex items-center gap-1.5", className)}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={onLabelClick}
|
|
45
|
+
className={cn(
|
|
46
|
+
"text-caption text-[var(--color-text-muted)] underline-offset-2 hover:underline cursor-pointer",
|
|
47
|
+
!onLabelClick && "pointer-events-none"
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{label}:
|
|
51
|
+
</button>
|
|
52
|
+
{values.map((value, i) => (
|
|
53
|
+
<span
|
|
54
|
+
key={i}
|
|
55
|
+
className="inline-flex items-center gap-1 rounded-full border border-[var(--color-border-default)] px-2 py-0.5 text-caption text-[var(--color-text-default)]"
|
|
56
|
+
>
|
|
57
|
+
{value.label}
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={value.onRemove}
|
|
61
|
+
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-default)] transition-colors"
|
|
62
|
+
aria-label={`Remove ${value.label}`}
|
|
63
|
+
>
|
|
64
|
+
<XIcon className="size-3" />
|
|
65
|
+
</button>
|
|
66
|
+
</span>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { FilterChip }
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
CheckIcon,
|
|
6
|
+
ChevronLeftIcon,
|
|
7
|
+
ChevronRightIcon,
|
|
8
|
+
PlusIcon,
|
|
9
|
+
SearchIcon,
|
|
10
|
+
} from "lucide-react"
|
|
11
|
+
import type { DateRange } from "react-day-picker"
|
|
12
|
+
|
|
13
|
+
import { cn } from "../../lib/utils"
|
|
14
|
+
import { Button } from "./button"
|
|
15
|
+
import { Calendar } from "./calendar"
|
|
16
|
+
import { Input } from "./input"
|
|
17
|
+
import { Tabs, TabsList, TabsTrigger } from "./tabs"
|
|
18
|
+
|
|
19
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
interface FilterCategory {
|
|
22
|
+
id: string
|
|
23
|
+
label: string
|
|
24
|
+
type?: "list" | "date" | "amount"
|
|
25
|
+
values?: string[]
|
|
26
|
+
renderValue?: (value: string) => React.ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type AmountValue = { exact: number } | { min?: number; max?: number }
|
|
30
|
+
type CategoryValue = string | DateRange | AmountValue
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
type Step = 1 | 2 | 3
|
|
34
|
+
|
|
35
|
+
// ── Main component ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function FilterCombobox({
|
|
38
|
+
categories,
|
|
39
|
+
onApply,
|
|
40
|
+
activeFilters = {},
|
|
41
|
+
trigger,
|
|
42
|
+
dropdownAlign = "left",
|
|
43
|
+
className,
|
|
44
|
+
}: {
|
|
45
|
+
categories: FilterCategory[]
|
|
46
|
+
onApply: (categoryId: string, values: CategoryValue[]) => void
|
|
47
|
+
activeFilters?: Record<string, CategoryValue[]>
|
|
48
|
+
trigger?: (props: { open: boolean; onClick: () => void }) => React.ReactNode
|
|
49
|
+
dropdownAlign?: "left" | "right"
|
|
50
|
+
className?: string
|
|
51
|
+
}) {
|
|
52
|
+
const [open, setOpen] = React.useState(false)
|
|
53
|
+
const [step, setStep] = React.useState<Step>(1)
|
|
54
|
+
const [selectedCategory, setSelectedCategory] = React.useState<FilterCategory | null>(null)
|
|
55
|
+
const [selectedValues, setSelectedValues] = React.useState<Set<string>>(new Set())
|
|
56
|
+
const [dateRange, setDateRange] = React.useState<DateRange | undefined>()
|
|
57
|
+
const [amountMode, setAmountMode] = React.useState<"exact" | "range">("exact")
|
|
58
|
+
const [amountExact, setAmountExact] = React.useState("")
|
|
59
|
+
const [amountMin, setAmountMin] = React.useState("")
|
|
60
|
+
const [amountMax, setAmountMax] = React.useState("")
|
|
61
|
+
const [search, setSearch] = React.useState("")
|
|
62
|
+
|
|
63
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
64
|
+
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
65
|
+
|
|
66
|
+
// Close on outside click
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (!open) return
|
|
69
|
+
const handler = (e: MouseEvent) => {
|
|
70
|
+
if (!containerRef.current?.contains(e.target as Node)) handleClose()
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener("mousedown", handler)
|
|
73
|
+
return () => document.removeEventListener("mousedown", handler)
|
|
74
|
+
}, [open])
|
|
75
|
+
|
|
76
|
+
// Auto-focus search when entering step 2
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (open && step === 2) setTimeout(() => searchRef.current?.focus(), 30)
|
|
79
|
+
}, [open, step])
|
|
80
|
+
|
|
81
|
+
function handleClose() {
|
|
82
|
+
setOpen(false)
|
|
83
|
+
setStep(1)
|
|
84
|
+
setSelectedCategory(null)
|
|
85
|
+
setSelectedValues(new Set())
|
|
86
|
+
setDateRange(undefined)
|
|
87
|
+
setSearch("")
|
|
88
|
+
setAmountMode("exact")
|
|
89
|
+
setAmountExact("")
|
|
90
|
+
setAmountMin("")
|
|
91
|
+
setAmountMax("")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openCategory(cat: FilterCategory) {
|
|
95
|
+
const existing = activeFilters[cat.id] ?? []
|
|
96
|
+
|
|
97
|
+
if (cat.type === "date") {
|
|
98
|
+
const custom = existing.find((v): v is DateRange => typeof v === "object" && "from" in v)
|
|
99
|
+
setSelectedCategory(cat)
|
|
100
|
+
setDateRange(custom)
|
|
101
|
+
setSelectedValues(new Set())
|
|
102
|
+
setSearch("")
|
|
103
|
+
setStep(3)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (cat.type === "amount") {
|
|
108
|
+
const custom = existing.find((v): v is AmountValue =>
|
|
109
|
+
typeof v === "object" && ("exact" in v || "min" in v || "max" in v)
|
|
110
|
+
)
|
|
111
|
+
setSelectedCategory(cat)
|
|
112
|
+
setSelectedValues(new Set())
|
|
113
|
+
setSearch("")
|
|
114
|
+
if (custom) {
|
|
115
|
+
if ("exact" in custom) {
|
|
116
|
+
setAmountMode("exact")
|
|
117
|
+
setAmountExact(String(custom.exact))
|
|
118
|
+
setAmountMin("")
|
|
119
|
+
setAmountMax("")
|
|
120
|
+
} else {
|
|
121
|
+
const r = custom as { min?: number; max?: number }
|
|
122
|
+
setAmountMode("range")
|
|
123
|
+
setAmountExact("")
|
|
124
|
+
setAmountMin(r.min != null ? String(r.min) : "")
|
|
125
|
+
setAmountMax(r.max != null ? String(r.max) : "")
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setStep(3)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setSelectedValues(new Set(existing.filter((v): v is string => typeof v === "string")))
|
|
133
|
+
setSelectedCategory(cat)
|
|
134
|
+
setSearch("")
|
|
135
|
+
setStep(2)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function applyValues() {
|
|
139
|
+
if (!selectedCategory) return
|
|
140
|
+
onApply(selectedCategory.id, Array.from(selectedValues))
|
|
141
|
+
handleClose()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function applyCustomDate() {
|
|
145
|
+
if (!selectedCategory || !dateRange?.from) return
|
|
146
|
+
onApply(selectedCategory.id, [dateRange])
|
|
147
|
+
handleClose()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyCustomAmount() {
|
|
151
|
+
if (!selectedCategory) return
|
|
152
|
+
let value: AmountValue
|
|
153
|
+
if (amountMode === "exact") {
|
|
154
|
+
const n = parseFloat(amountExact)
|
|
155
|
+
if (isNaN(n)) return
|
|
156
|
+
value = { exact: n }
|
|
157
|
+
} else {
|
|
158
|
+
const min = amountMin !== "" ? parseFloat(amountMin) : undefined
|
|
159
|
+
const max = amountMax !== "" ? parseFloat(amountMax) : undefined
|
|
160
|
+
if (min == null && max == null) return
|
|
161
|
+
value = { min, max }
|
|
162
|
+
}
|
|
163
|
+
onApply(selectedCategory.id, [value])
|
|
164
|
+
handleClose()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toggleValue(value: string, singleSelect = false) {
|
|
168
|
+
setSelectedValues(prev => {
|
|
169
|
+
if (singleSelect) return prev.has(value) ? new Set() : new Set([value])
|
|
170
|
+
const next = new Set(prev)
|
|
171
|
+
next.has(value) ? next.delete(value) : next.add(value)
|
|
172
|
+
return next
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Derived values ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const isDate = selectedCategory?.type === "date"
|
|
179
|
+
const isAmount = selectedCategory?.type === "amount"
|
|
180
|
+
|
|
181
|
+
const step2AllValues = selectedCategory?.values ?? []
|
|
182
|
+
|
|
183
|
+
const showSearch = step2AllValues.length > 4
|
|
184
|
+
const step2Filtered = showSearch
|
|
185
|
+
? step2AllValues.filter(v => v.toLowerCase().includes(search.toLowerCase()))
|
|
186
|
+
: step2AllValues
|
|
187
|
+
|
|
188
|
+
const canApplyAmount = amountMode === "exact"
|
|
189
|
+
? amountExact !== "" && !isNaN(parseFloat(amountExact))
|
|
190
|
+
: amountMin !== "" || amountMax !== ""
|
|
191
|
+
|
|
192
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div ref={containerRef} className={cn("relative inline-block", className)}>
|
|
196
|
+
{trigger ? trigger({ open, onClick: () => setOpen(v => !v) }) : (
|
|
197
|
+
<Button variant="default" size="sm" className="h-7 text-caption gap-1.5 px-2.5" onClick={() => setOpen(v => !v)}>
|
|
198
|
+
<PlusIcon className="size-3.5" />
|
|
199
|
+
Add filter
|
|
200
|
+
</Button>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{open && (
|
|
204
|
+
<div className={cn(
|
|
205
|
+
dropdownAlign === "right" ? "absolute right-0 top-full mt-1.5 overflow-hidden [border-radius:var(--radius-popover)]" : "absolute left-0 top-full mt-1.5 overflow-hidden [border-radius:var(--radius-popover)]",
|
|
206
|
+
"bg-[var(--color-bg-overlay)] shadow-[var(--shadow-popover)] ring-1 ring-[var(--color-border-subtle)]",
|
|
207
|
+
"[z-index:var(--z-dropdown)]",
|
|
208
|
+
step === 3 && isDate ? "w-auto" : "w-56"
|
|
209
|
+
)}>
|
|
210
|
+
|
|
211
|
+
{/* Step 1 — category list */}
|
|
212
|
+
{step === 1 && (
|
|
213
|
+
<ul className="p-1">
|
|
214
|
+
{categories.map(cat => (
|
|
215
|
+
<li key={cat.id}>
|
|
216
|
+
<PickerRow onClick={() => openCategory(cat)}>{cat.label}</PickerRow>
|
|
217
|
+
</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* Step 2 — value list */}
|
|
223
|
+
{step === 2 && selectedCategory && (
|
|
224
|
+
<>
|
|
225
|
+
<StepHeader
|
|
226
|
+
title={selectedCategory.label}
|
|
227
|
+
onBack={() => { setStep(1); setSearch("") }}
|
|
228
|
+
/>
|
|
229
|
+
{showSearch && (
|
|
230
|
+
<div className="px-1 pt-1">
|
|
231
|
+
<SearchInput ref={searchRef} value={search} onChange={setSearch} />
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
<ul className="max-h-52 overflow-y-auto p-1">
|
|
235
|
+
{step2Filtered.length === 0
|
|
236
|
+
? <EmptyRow />
|
|
237
|
+
: step2Filtered.map(value => (
|
|
238
|
+
<li key={value}>
|
|
239
|
+
<CheckRow
|
|
240
|
+
checked={selectedValues.has(value)}
|
|
241
|
+
onToggle={() => toggleValue(value)}
|
|
242
|
+
>
|
|
243
|
+
{selectedCategory.renderValue?.(value) ?? value}
|
|
244
|
+
</CheckRow>
|
|
245
|
+
</li>
|
|
246
|
+
))
|
|
247
|
+
}
|
|
248
|
+
</ul>
|
|
249
|
+
{selectedValues.size > 0 && (
|
|
250
|
+
<div className="p-1">
|
|
251
|
+
<Button size="sm" className="w-full" onClick={applyValues}>
|
|
252
|
+
Apply
|
|
253
|
+
</Button>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{/* Step 3 — custom date range */}
|
|
260
|
+
{step === 3 && isDate && (
|
|
261
|
+
<>
|
|
262
|
+
<StepHeader
|
|
263
|
+
title={selectedCategory?.label ?? "Date"}
|
|
264
|
+
onBack={() => setStep(1)}
|
|
265
|
+
/>
|
|
266
|
+
<div className="p-1">
|
|
267
|
+
<Calendar mode="range" selected={dateRange} onSelect={setDateRange} numberOfMonths={1} />
|
|
268
|
+
</div>
|
|
269
|
+
{dateRange?.from && (
|
|
270
|
+
<div className="p-1">
|
|
271
|
+
<Button size="sm" className="w-full" onClick={applyCustomDate}>
|
|
272
|
+
Apply
|
|
273
|
+
</Button>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{/* Step 3 — custom amount (no step 2 for amount — opens here directly) */}
|
|
280
|
+
{step === 3 && isAmount && (
|
|
281
|
+
<>
|
|
282
|
+
<StepHeader
|
|
283
|
+
title="Amount"
|
|
284
|
+
onBack={() => { setStep(1) }}
|
|
285
|
+
/>
|
|
286
|
+
<div className="px-2 pb-2 flex flex-col gap-2">
|
|
287
|
+
<Tabs
|
|
288
|
+
value={amountMode}
|
|
289
|
+
onValueChange={v => setAmountMode(v as "exact" | "range")}
|
|
290
|
+
className="w-full"
|
|
291
|
+
>
|
|
292
|
+
<TabsList className="w-full">
|
|
293
|
+
<TabsTrigger value="exact" className="flex-1">Exact</TabsTrigger>
|
|
294
|
+
<TabsTrigger value="range" className="flex-1">Range</TabsTrigger>
|
|
295
|
+
</TabsList>
|
|
296
|
+
</Tabs>
|
|
297
|
+
|
|
298
|
+
{amountMode === "exact" ? (
|
|
299
|
+
<div className="relative">
|
|
300
|
+
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-body-sm text-[var(--color-text-muted)]">$</span>
|
|
301
|
+
<Input
|
|
302
|
+
type="number"
|
|
303
|
+
min="0"
|
|
304
|
+
value={amountExact}
|
|
305
|
+
onChange={e => setAmountExact(e.target.value)}
|
|
306
|
+
placeholder="0"
|
|
307
|
+
className="pl-6 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
) : (
|
|
311
|
+
<div className="flex items-center gap-2">
|
|
312
|
+
<div className="relative flex-1">
|
|
313
|
+
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-body-sm text-[var(--color-text-muted)]">$</span>
|
|
314
|
+
<Input
|
|
315
|
+
type="number"
|
|
316
|
+
min="0"
|
|
317
|
+
value={amountMin}
|
|
318
|
+
onChange={e => setAmountMin(e.target.value)}
|
|
319
|
+
placeholder="Min"
|
|
320
|
+
className="pl-6 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
<span className="text-body-sm text-[var(--color-text-muted)] shrink-0">–</span>
|
|
324
|
+
<div className="relative flex-1">
|
|
325
|
+
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-body-sm text-[var(--color-text-muted)]">$</span>
|
|
326
|
+
<Input
|
|
327
|
+
type="number"
|
|
328
|
+
min="0"
|
|
329
|
+
value={amountMax}
|
|
330
|
+
onChange={e => setAmountMax(e.target.value)}
|
|
331
|
+
placeholder="Max"
|
|
332
|
+
className="pl-6 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{canApplyAmount && (
|
|
339
|
+
<Button size="sm" className="w-full" onClick={applyCustomAmount}>
|
|
340
|
+
Apply
|
|
341
|
+
</Button>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
</div>
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Internal sub-components ────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
const SearchInput = React.forwardRef<
|
|
356
|
+
HTMLInputElement,
|
|
357
|
+
{ value: string; onChange: (v: string) => void }
|
|
358
|
+
>(({ value, onChange }, ref) => (
|
|
359
|
+
<div className="flex h-8 items-center gap-2 [border-radius:var(--radius-input)] border border-[var(--color-border-default)]/30 bg-[var(--color-bg-canvas)]/60 px-2">
|
|
360
|
+
<SearchIcon className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
|
361
|
+
<input
|
|
362
|
+
ref={ref}
|
|
363
|
+
value={value}
|
|
364
|
+
onChange={e => onChange(e.target.value)}
|
|
365
|
+
placeholder="Search…"
|
|
366
|
+
className="min-w-0 flex-1 bg-transparent text-body-sm outline-none placeholder:text-[var(--color-text-hint)]"
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
))
|
|
370
|
+
SearchInput.displayName = "SearchInput"
|
|
371
|
+
|
|
372
|
+
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
|
373
|
+
return (
|
|
374
|
+
<div className="flex items-center gap-1 px-1 pt-1">
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={onBack}
|
|
378
|
+
className="flex items-center justify-center [border-radius:var(--radius-tag)] p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-action-ghost-hover)] transition-colors"
|
|
379
|
+
>
|
|
380
|
+
<ChevronLeftIcon className="size-4" />
|
|
381
|
+
</button>
|
|
382
|
+
<span className="text-body-sm font-medium text-[var(--color-text-default)]">{title}</span>
|
|
383
|
+
</div>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function PickerRow({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
|
388
|
+
return (
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={onClick}
|
|
392
|
+
className="relative flex w-full items-center gap-2 [border-radius:var(--radius-tag)] py-1.5 pl-2 pr-8 text-body-sm text-[var(--color-text-default)] hover:bg-[var(--color-action-ghost-hover)] transition-colors"
|
|
393
|
+
>
|
|
394
|
+
{children}
|
|
395
|
+
<ChevronRightIcon className="pointer-events-none absolute right-2 size-3.5 text-[var(--color-text-muted)]" />
|
|
396
|
+
</button>
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function CheckRow({
|
|
401
|
+
children,
|
|
402
|
+
checked,
|
|
403
|
+
onToggle,
|
|
404
|
+
}: {
|
|
405
|
+
children: React.ReactNode
|
|
406
|
+
checked: boolean
|
|
407
|
+
onToggle: () => void
|
|
408
|
+
}) {
|
|
409
|
+
return (
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={onToggle}
|
|
413
|
+
className="relative flex w-full items-center gap-2 [border-radius:var(--radius-tag)] py-1.5 pl-2 pr-8 text-body-sm text-[var(--color-text-default)] hover:bg-[var(--color-action-ghost-hover)] transition-colors"
|
|
414
|
+
>
|
|
415
|
+
{children}
|
|
416
|
+
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
|
417
|
+
{checked && <CheckIcon className="size-4 text-[var(--color-text-default)]" />}
|
|
418
|
+
</span>
|
|
419
|
+
</button>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function EmptyRow() {
|
|
424
|
+
return <li className="py-2 text-center text-body-sm text-[var(--color-text-muted)]">No results</li>
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Exports ────────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
export { FilterCombobox }
|
|
430
|
+
export type { FilterCategory, CategoryValue, AmountValue }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../lib/utils"
|
|
3
|
+
|
|
4
|
+
export function Kbd({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<kbd
|
|
7
|
+
className={cn(
|
|
8
|
+
"inline-flex items-center gap-1 h-5 font-sans [border-radius:var(--radius-tooltip)] border border-[var(--color-border-default)] bg-[var(--color-bg-canvas)] px-1.5 text-caption text-[var(--color-text-muted)]",
|
|
9
|
+
className
|
|
10
|
+
)}
|
|
11
|
+
>
|
|
12
|
+
{children}
|
|
13
|
+
</kbd>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { PlusIcon, SearchIcon, XIcon } from "lucide-react"
|
|
5
|
+
import type { DateRange } from "react-day-picker"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
import { Button } from "./button"
|
|
9
|
+
import { FilterChip } from "./filter-chip"
|
|
10
|
+
import { FilterCombobox } from "./filter-combobox"
|
|
11
|
+
import type { FilterCategory, CategoryValue } from "./filter-combobox"
|
|
12
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "./input-group"
|
|
13
|
+
import { Kbd } from "./kbd"
|
|
14
|
+
|
|
15
|
+
// ── Default label formatter ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function defaultFilterValueLabel(_categoryId: string, value: CategoryValue): string {
|
|
18
|
+
if (typeof value === "string") return value
|
|
19
|
+
if (typeof value === "object" && "from" in value) {
|
|
20
|
+
const v = value as DateRange
|
|
21
|
+
if (!v.from) return ""
|
|
22
|
+
const from = v.from.toLocaleDateString("default", { month: "short", day: "numeric" })
|
|
23
|
+
const to = v.to?.toLocaleDateString("default", { month: "short", day: "numeric" })
|
|
24
|
+
return to ? `${from} – ${to}` : `From ${from}`
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === "object" && "exact" in value) {
|
|
27
|
+
return (value as { exact: number }).exact.toLocaleString("en-US")
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === "object") {
|
|
30
|
+
const { min, max } = value as { min?: number; max?: number }
|
|
31
|
+
if (min != null && max != null) return `${min.toLocaleString()} – ${max.toLocaleString()}`
|
|
32
|
+
if (min != null) return `> ${min.toLocaleString()}`
|
|
33
|
+
if (max != null) return `< ${max.toLocaleString()}`
|
|
34
|
+
}
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
interface SearchBarProps {
|
|
41
|
+
placeholder?: string
|
|
42
|
+
value: string
|
|
43
|
+
onChange: (value: string) => void
|
|
44
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
|
45
|
+
onFocus?: () => void
|
|
46
|
+
inputRef?: React.RefObject<HTMLInputElement | null>
|
|
47
|
+
kbdHint?: React.ReactNode
|
|
48
|
+
|
|
49
|
+
filterCategories?: FilterCategory[]
|
|
50
|
+
activeFilters?: Record<string, CategoryValue[]>
|
|
51
|
+
onApplyFilter?: (categoryId: string, values: CategoryValue[]) => void
|
|
52
|
+
onRemoveFilter?: (categoryId: string, value: CategoryValue) => void
|
|
53
|
+
onClearFilters?: () => void
|
|
54
|
+
filterValueLabel?: (categoryId: string, value: CategoryValue) => string
|
|
55
|
+
|
|
56
|
+
children?: React.ReactNode
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function SearchBar({
|
|
63
|
+
placeholder,
|
|
64
|
+
value,
|
|
65
|
+
onChange,
|
|
66
|
+
onKeyDown,
|
|
67
|
+
onFocus,
|
|
68
|
+
inputRef,
|
|
69
|
+
kbdHint,
|
|
70
|
+
filterCategories = [],
|
|
71
|
+
activeFilters = {},
|
|
72
|
+
onApplyFilter,
|
|
73
|
+
onRemoveFilter,
|
|
74
|
+
onClearFilters,
|
|
75
|
+
filterValueLabel = defaultFilterValueLabel,
|
|
76
|
+
children,
|
|
77
|
+
className,
|
|
78
|
+
}: SearchBarProps) {
|
|
79
|
+
const hasActiveFilters = Object.values(activeFilters).some(v => v.length > 0)
|
|
80
|
+
const hasFilterCategories = filterCategories.length > 0
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div data-search-bar className={cn("relative flex flex-col", className)}>
|
|
84
|
+
|
|
85
|
+
{/* Search input row */}
|
|
86
|
+
<InputGroup
|
|
87
|
+
className={cn(
|
|
88
|
+
"h-12",
|
|
89
|
+
hasActiveFilters && "[border-bottom-left-radius:0] [border-bottom-right-radius:0]"
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<InputGroupAddon align="inline-start">
|
|
93
|
+
<SearchIcon className="size-4" />
|
|
94
|
+
</InputGroupAddon>
|
|
95
|
+
<InputGroupInput
|
|
96
|
+
ref={inputRef}
|
|
97
|
+
placeholder={placeholder}
|
|
98
|
+
value={value}
|
|
99
|
+
onChange={e => onChange(e.target.value)}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
onFocus={onFocus}
|
|
102
|
+
autoComplete="off"
|
|
103
|
+
/>
|
|
104
|
+
{(!!value || (hasFilterCategories && !hasActiveFilters)) && (
|
|
105
|
+
<InputGroupAddon align="inline-end">
|
|
106
|
+
{value && (
|
|
107
|
+
<InputGroupButton size="sm" variant="ghost" onClick={() => onChange("")} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-default)]">
|
|
108
|
+
<XIcon className="size-4" />
|
|
109
|
+
</InputGroupButton>
|
|
110
|
+
)}
|
|
111
|
+
{hasFilterCategories && !hasActiveFilters && (
|
|
112
|
+
<>
|
|
113
|
+
{!value && kbdHint && <Kbd>{kbdHint}</Kbd>}
|
|
114
|
+
<span className="h-4 w-px bg-[var(--color-border-default)]" />
|
|
115
|
+
<FilterCombobox
|
|
116
|
+
categories={filterCategories}
|
|
117
|
+
onApply={onApplyFilter ?? (() => {})}
|
|
118
|
+
activeFilters={activeFilters}
|
|
119
|
+
dropdownAlign="right"
|
|
120
|
+
trigger={({ onClick }) => (
|
|
121
|
+
<InputGroupButton size="sm" variant="ghost" onClick={onClick} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-default)]">
|
|
122
|
+
Advanced search
|
|
123
|
+
</InputGroupButton>
|
|
124
|
+
)}
|
|
125
|
+
/>
|
|
126
|
+
</>
|
|
127
|
+
)}
|
|
128
|
+
</InputGroupAddon>
|
|
129
|
+
)}
|
|
130
|
+
</InputGroup>
|
|
131
|
+
|
|
132
|
+
{/* Filter bar — visible only when filters are active */}
|
|
133
|
+
{hasActiveFilters && (
|
|
134
|
+
<div className="flex flex-wrap items-center gap-3 [border-bottom-left-radius:var(--radius-card)] [border-bottom-right-radius:var(--radius-card)] border border-t-0 border-[var(--color-border-default)] bg-[var(--color-bg-raised)] px-3 py-2.5">
|
|
135
|
+
{Object.entries(activeFilters)
|
|
136
|
+
.filter(([, vals]) => vals.length > 0)
|
|
137
|
+
.map(([categoryId, values]) => {
|
|
138
|
+
const cat = filterCategories.find(c => c.id === categoryId)
|
|
139
|
+
return (
|
|
140
|
+
<FilterChip
|
|
141
|
+
key={categoryId}
|
|
142
|
+
label={cat?.label ?? categoryId}
|
|
143
|
+
values={values.map(v => ({
|
|
144
|
+
label: filterValueLabel(categoryId, v),
|
|
145
|
+
onRemove: () => onRemoveFilter?.(categoryId, v),
|
|
146
|
+
}))}
|
|
147
|
+
onLabelClick={() => {}}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
<FilterCombobox
|
|
152
|
+
categories={filterCategories}
|
|
153
|
+
onApply={onApplyFilter ?? (() => {})}
|
|
154
|
+
activeFilters={activeFilters}
|
|
155
|
+
trigger={({ onClick }) => (
|
|
156
|
+
<Button variant="default" size="sm" className="h-7 text-caption gap-1.5 px-2.5" onClick={onClick}>
|
|
157
|
+
<PlusIcon className="size-3.5" />
|
|
158
|
+
Add
|
|
159
|
+
</Button>
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
<FilterChip
|
|
163
|
+
variant="clear-all"
|
|
164
|
+
className="ml-auto"
|
|
165
|
+
onClear={onClearFilters ?? (() => {})}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* Results dropdown — consumer-provided slot */}
|
|
171
|
+
{children}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Exports ────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export { SearchBar }
|
|
179
|
+
export type { SearchBarProps }
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Utilities
|
|
2
2
|
export { cn } from "./lib/utils"
|
|
3
3
|
|
|
4
|
+
// Hooks
|
|
5
|
+
export { usePlatform } from "./hooks/use-platform"
|
|
6
|
+
|
|
4
7
|
// Components
|
|
5
8
|
export * from "./components/ui/badge"
|
|
6
|
-
export * from "./components/ui/cell"
|
|
7
9
|
export * from "./components/ui/breadcrumb"
|
|
10
|
+
export * from "./components/ui/calendar"
|
|
11
|
+
export * from "./components/ui/cell"
|
|
8
12
|
export * from "./components/ui/button"
|
|
9
13
|
export * from "./components/ui/button-group"
|
|
10
14
|
export * from "./components/ui/card"
|
|
@@ -13,8 +17,12 @@ export * from "./components/ui/combobox"
|
|
|
13
17
|
export * from "./components/ui/data-table"
|
|
14
18
|
export * from "./components/ui/dialog"
|
|
15
19
|
export * from "./components/ui/dropdown-menu"
|
|
20
|
+
export * from "./components/ui/filter-chip"
|
|
21
|
+
export * from "./components/ui/filter-combobox"
|
|
22
|
+
export * from "./components/ui/search-bar"
|
|
16
23
|
export * from "./components/ui/input"
|
|
17
24
|
export * from "./components/ui/input-group"
|
|
25
|
+
export * from "./components/ui/kbd"
|
|
18
26
|
export * from "./components/ui/label"
|
|
19
27
|
export * from "./components/ui/pagination"
|
|
20
28
|
export * from "./components/ui/select"
|
package/tokens/primitives.css
CHANGED
|
@@ -218,9 +218,10 @@
|
|
|
218
218
|
--primitive-shadow-xl: 0 20px 25px oklch(0 0 0 / 0.10), 0 8px 10px oklch(0 0 0 / 0.04);
|
|
219
219
|
|
|
220
220
|
/* --- Z-index scale --- */
|
|
221
|
-
--primitive-z-base:
|
|
222
|
-
--primitive-z-raised:
|
|
223
|
-
--primitive-z-
|
|
221
|
+
--primitive-z-base: 0;
|
|
222
|
+
--primitive-z-raised: 10;
|
|
223
|
+
--primitive-z-dropdown: 20;
|
|
224
|
+
--primitive-z-overlay: 100;
|
|
224
225
|
--primitive-z-modal: 200;
|
|
225
226
|
--primitive-z-toast: 300;
|
|
226
227
|
--primitive-z-tooltip: 400;
|