minka-ds 0.1.2 → 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 +1 -1
- package/src/components/ui/filter-chip.tsx +72 -0
- package/src/components/ui/filter-combobox.tsx +430 -0
- package/src/components/ui/search-bar.tsx +179 -0
- package/src/index.ts +5 -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 }
|
|
@@ -155,7 +155,7 @@ function DataTable<TData, TValue>({
|
|
|
155
155
|
>
|
|
156
156
|
|
|
157
157
|
<Table className="[&_th:first-child]:pl-4 [&_td:first-child]:pl-4">
|
|
158
|
-
<TableHeader className="sticky top-0 z-
|
|
158
|
+
<TableHeader className="sticky top-0 [z-index:var(--z-sticky)] bg-[var(--color-bg-base)]">
|
|
159
159
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
160
160
|
<TableRow key={headerGroup.id}>
|
|
161
161
|
{headerGroup.headers.map((header, index) => (
|
|
@@ -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,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
|
@@ -6,8 +6,9 @@ export { usePlatform } from "./hooks/use-platform"
|
|
|
6
6
|
|
|
7
7
|
// Components
|
|
8
8
|
export * from "./components/ui/badge"
|
|
9
|
-
export * from "./components/ui/cell"
|
|
10
9
|
export * from "./components/ui/breadcrumb"
|
|
10
|
+
export * from "./components/ui/calendar"
|
|
11
|
+
export * from "./components/ui/cell"
|
|
11
12
|
export * from "./components/ui/button"
|
|
12
13
|
export * from "./components/ui/button-group"
|
|
13
14
|
export * from "./components/ui/card"
|
|
@@ -16,6 +17,9 @@ export * from "./components/ui/combobox"
|
|
|
16
17
|
export * from "./components/ui/data-table"
|
|
17
18
|
export * from "./components/ui/dialog"
|
|
18
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"
|
|
19
23
|
export * from "./components/ui/input"
|
|
20
24
|
export * from "./components/ui/input-group"
|
|
21
25
|
export * from "./components/ui/kbd"
|
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;
|