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 CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "minka-ds",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
- "files": ["src", "tokens"],
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
- pageSize?: number
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
- pageSize = 10,
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="space-y-4">
219
- <div className="rounded-[var(--radius-card)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] overflow-hidden">
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
- <DataTablePagination table={table} />
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 }
@@ -0,0 +1,11 @@
1
+ import * as React from "react"
2
+
3
+ export function usePlatform() {
4
+ const [isMac, setIsMac] = React.useState(true)
5
+
6
+ React.useEffect(() => {
7
+ setIsMac(navigator.userAgent.includes("Mac"))
8
+ }, [])
9
+
10
+ return { isMac }
11
+ }
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"
@@ -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: 0;
222
- --primitive-z-raised: 10;
223
- --primitive-z-overlay: 100;
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;