@eggspot/ui 0.0.0
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/eslint.config.js +4 -0
- package/package.json +66 -0
- package/postcss.config.mjs +1 -0
- package/src/components/Button.machine.tsx +50 -0
- package/src/components/Button.tsx +249 -0
- package/src/components/Button.variants.tsx +186 -0
- package/src/components/ButtonGroup.tsx +56 -0
- package/src/components/Calendar.tsx +275 -0
- package/src/components/Calendar.utils.tsx +22 -0
- package/src/components/Checkbox.tsx +199 -0
- package/src/components/ConfirmDialog.tsx +183 -0
- package/src/components/DashboardLayout/DashboardLayout.tsx +348 -0
- package/src/components/DashboardLayout/SidebarNav.tsx +509 -0
- package/src/components/DashboardLayout/index.ts +33 -0
- package/src/components/DataTable/DataTable.tsx +557 -0
- package/src/components/DataTable/DataTableColumnHeader.tsx +122 -0
- package/src/components/DataTable/DataTableDisplaySettings.tsx +265 -0
- package/src/components/DataTable/DataTableFloatingBar.tsx +44 -0
- package/src/components/DataTable/DataTablePagination.tsx +168 -0
- package/src/components/DataTable/DataTableStates.tsx +69 -0
- package/src/components/DataTable/DataTableToolbarContainer.tsx +47 -0
- package/src/components/DataTable/hooks/use-data-table-settings.ts +101 -0
- package/src/components/DataTable/index.ts +7 -0
- package/src/components/DataTable/types/data-table.ts +97 -0
- package/src/components/DatePicker.tsx +213 -0
- package/src/components/DatePicker.utils.tsx +38 -0
- package/src/components/Datefield.tsx +109 -0
- package/src/components/Datefield.utils.ts +10 -0
- package/src/components/Dialog.tsx +167 -0
- package/src/components/Field.tsx +49 -0
- package/src/components/Filter/Filter.store.tsx +122 -0
- package/src/components/Filter/Filter.tsx +11 -0
- package/src/components/Filter/Filter.types.ts +107 -0
- package/src/components/Filter/FilterBar.tsx +38 -0
- package/src/components/Filter/FilterBuilder.tsx +158 -0
- package/src/components/Filter/FilterField/DateModeRowValue.tsx +250 -0
- package/src/components/Filter/FilterField/FilterAsyncSelect.tsx +191 -0
- package/src/components/Filter/FilterField/FilterDateMode.tsx +241 -0
- package/src/components/Filter/FilterField/FilterDateRange.tsx +169 -0
- package/src/components/Filter/FilterField/FilterSelect.tsx +208 -0
- package/src/components/Filter/FilterField/FilterSingleDate.tsx +277 -0
- package/src/components/Filter/FilterField/OptionItem.tsx +112 -0
- package/src/components/Filter/FilterField/index.ts +6 -0
- package/src/components/Filter/FilterRow.tsx +527 -0
- package/src/components/Filter/index.ts +17 -0
- package/src/components/Form.tsx +195 -0
- package/src/components/Heading.tsx +41 -0
- package/src/components/Input.tsx +221 -0
- package/src/components/InputOTP.tsx +78 -0
- package/src/components/Label.tsx +65 -0
- package/src/components/Layout.tsx +129 -0
- package/src/components/ListBox.tsx +97 -0
- package/src/components/Menu.tsx +152 -0
- package/src/components/NativeSelect.tsx +77 -0
- package/src/components/NumberInput.tsx +114 -0
- package/src/components/Popover.tsx +44 -0
- package/src/components/Provider.tsx +22 -0
- package/src/components/RadioGroup.tsx +191 -0
- package/src/components/Resizable.tsx +71 -0
- package/src/components/ScrollArea.tsx +57 -0
- package/src/components/Select.tsx +626 -0
- package/src/components/Select.utils.tsx +64 -0
- package/src/components/Separator.tsx +25 -0
- package/src/components/Sheet.tsx +147 -0
- package/src/components/Sonner.tsx +96 -0
- package/src/components/Spinner.tsx +30 -0
- package/src/components/Switch.tsx +51 -0
- package/src/components/Text.tsx +35 -0
- package/src/components/Tooltip.tsx +58 -0
- package/src/consts/config.ts +2 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/lib/utils.ts +10 -0
- package/tsconfig.json +11 -0
- package/tsconfig.lint.json +8 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useContext, useMemo, useState } from "react"
|
|
4
|
+
import { SearchInput } from "@eggspot/ui/components/Input"
|
|
5
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
6
|
+
import { Check, Minus } from "lucide-react"
|
|
7
|
+
import {
|
|
8
|
+
Menu as AriaMenu,
|
|
9
|
+
MenuItem as AriaMenuItem,
|
|
10
|
+
Collection,
|
|
11
|
+
ListLayout,
|
|
12
|
+
RootMenuTriggerStateContext,
|
|
13
|
+
Virtualizer,
|
|
14
|
+
} from "react-aria-components"
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
useFilterContext,
|
|
18
|
+
useFilterItem,
|
|
19
|
+
useFilterItemClose,
|
|
20
|
+
} from "../Filter.store"
|
|
21
|
+
import type { SelectOption } from "../Filter.types"
|
|
22
|
+
import { isSelectOptionArray } from "../Filter.types"
|
|
23
|
+
import { MenuOptionItem } from "./OptionItem"
|
|
24
|
+
|
|
25
|
+
interface FilterSelectProps {
|
|
26
|
+
field?: string
|
|
27
|
+
options?: SelectOption[]
|
|
28
|
+
multi?: boolean
|
|
29
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
30
|
+
placeholder?: string
|
|
31
|
+
emptyMessage?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FilterSelect({
|
|
35
|
+
field: fieldProp,
|
|
36
|
+
options: optionsProp,
|
|
37
|
+
multi = true,
|
|
38
|
+
renderIcon,
|
|
39
|
+
placeholder = "Filter...",
|
|
40
|
+
emptyMessage = "No matching options",
|
|
41
|
+
}: FilterSelectProps) {
|
|
42
|
+
const { value: filters, setFieldValue } = useFilterContext()
|
|
43
|
+
const filterItem = useFilterItem()
|
|
44
|
+
const onClose = useFilterItemClose()
|
|
45
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
46
|
+
|
|
47
|
+
const field = fieldProp ?? filterItem?.field
|
|
48
|
+
const options = optionsProp ?? filterItem?.options ?? []
|
|
49
|
+
|
|
50
|
+
if (!field) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"FilterSelect requires a field prop or must be used within a FilterItemProvider"
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [search, setSearch] = useState("")
|
|
57
|
+
|
|
58
|
+
const raw = filters[field]
|
|
59
|
+
|
|
60
|
+
const selectedValues = useMemo(() => {
|
|
61
|
+
if (!raw) return []
|
|
62
|
+
if (isSelectOptionArray(raw)) {
|
|
63
|
+
return raw.map((opt) => opt.value)
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(raw)) {
|
|
66
|
+
return raw.map(String)
|
|
67
|
+
}
|
|
68
|
+
return [String(raw)]
|
|
69
|
+
}, [raw])
|
|
70
|
+
|
|
71
|
+
const selectedKeys = useMemo(() => new Set(selectedValues), [selectedValues])
|
|
72
|
+
|
|
73
|
+
const filteredOptions = useMemo(() => {
|
|
74
|
+
if (!search) return options
|
|
75
|
+
const lowerSearch = search.toLowerCase()
|
|
76
|
+
return options.filter((opt) =>
|
|
77
|
+
opt.label.toLowerCase().includes(lowerSearch)
|
|
78
|
+
)
|
|
79
|
+
}, [options, search])
|
|
80
|
+
|
|
81
|
+
const optionsWithId = useMemo(
|
|
82
|
+
() => filteredOptions.map((opt) => ({ ...opt, id: String(opt.value) })),
|
|
83
|
+
[filteredOptions]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const handleSelectionChange = useCallback(
|
|
87
|
+
(keys: Set<React.Key>) => {
|
|
88
|
+
const selectedIds = Array.from(keys).map(String)
|
|
89
|
+
const selectedOptions = options
|
|
90
|
+
.filter((opt) => selectedIds.includes(String(opt.value)))
|
|
91
|
+
.map(({ icon, ...rest }) => rest)
|
|
92
|
+
|
|
93
|
+
setFieldValue(
|
|
94
|
+
field,
|
|
95
|
+
selectedOptions.length > 0 ? selectedOptions : undefined
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (!multi && selectedOptions.length > 0) {
|
|
99
|
+
menuState?.close()
|
|
100
|
+
onClose?.()
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
[field, options, setFieldValue, multi, onClose]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const handleSelectAll = useCallback(() => {
|
|
107
|
+
const isAllSelected = selectedValues.length === optionsWithId.length
|
|
108
|
+
|
|
109
|
+
if (isAllSelected) {
|
|
110
|
+
setFieldValue(field, undefined)
|
|
111
|
+
} else {
|
|
112
|
+
const allOptions = optionsWithId.map(({ icon, id, ...rest }) => rest)
|
|
113
|
+
setFieldValue(field, allOptions)
|
|
114
|
+
}
|
|
115
|
+
}, [field, optionsWithId, selectedValues.length, setFieldValue])
|
|
116
|
+
|
|
117
|
+
const isAllSelected =
|
|
118
|
+
selectedValues.length === optionsWithId.length && optionsWithId.length > 0
|
|
119
|
+
const isIndeterminate =
|
|
120
|
+
selectedValues.length > 0 && selectedValues.length < optionsWithId.length
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="flex max-h-[300px] min-w-[200px] flex-col">
|
|
124
|
+
<div className="border-gray-6 border-b px-3 py-2">
|
|
125
|
+
<SearchInput
|
|
126
|
+
placeholder={placeholder}
|
|
127
|
+
className="placeholder:text-gray-11 h-7 rounded border-0 bg-transparent pl-0 text-xs ring-0!"
|
|
128
|
+
onChange={setSearch}
|
|
129
|
+
value={search}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<Virtualizer
|
|
134
|
+
layout={ListLayout}
|
|
135
|
+
layoutOptions={{ estimatedRowHeight: 32 }}
|
|
136
|
+
>
|
|
137
|
+
<AriaMenu
|
|
138
|
+
aria-label="Filter options"
|
|
139
|
+
selectionMode={multi ? "multiple" : "single"}
|
|
140
|
+
selectedKeys={selectedKeys}
|
|
141
|
+
onSelectionChange={(keys) =>
|
|
142
|
+
handleSelectionChange(keys as Set<string>)
|
|
143
|
+
}
|
|
144
|
+
className="max-h-[250px] flex-1 overflow-auto py-1 outline-none"
|
|
145
|
+
renderEmptyState={() => (
|
|
146
|
+
<div className="text-gray-11 px-3 py-6 text-center text-xs">
|
|
147
|
+
{emptyMessage}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{multi && optionsWithId.length > 0 && (
|
|
152
|
+
<SelectAllItem
|
|
153
|
+
isAllSelected={isAllSelected}
|
|
154
|
+
isIndeterminate={isIndeterminate}
|
|
155
|
+
onSelectAll={handleSelectAll}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<Collection items={optionsWithId}>
|
|
160
|
+
{(option) => (
|
|
161
|
+
<MenuOptionItem
|
|
162
|
+
option={option}
|
|
163
|
+
multi={multi}
|
|
164
|
+
renderIcon={renderIcon}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
</Collection>
|
|
168
|
+
</AriaMenu>
|
|
169
|
+
</Virtualizer>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function SelectAllItem({
|
|
175
|
+
isAllSelected,
|
|
176
|
+
isIndeterminate,
|
|
177
|
+
onSelectAll,
|
|
178
|
+
}: {
|
|
179
|
+
isAllSelected: boolean
|
|
180
|
+
isIndeterminate: boolean
|
|
181
|
+
onSelectAll: () => void
|
|
182
|
+
}) {
|
|
183
|
+
return (
|
|
184
|
+
<AriaMenuItem
|
|
185
|
+
id="__select_all__"
|
|
186
|
+
textValue="Select All"
|
|
187
|
+
onAction={onSelectAll}
|
|
188
|
+
className={cn(
|
|
189
|
+
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-1.5 text-left text-sm outline-none",
|
|
190
|
+
"hover:bg-gray-3 data-focused:bg-gray-3 transition-colors",
|
|
191
|
+
"text-gray-11 font-medium"
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<span
|
|
195
|
+
className={cn(
|
|
196
|
+
"flex size-4 shrink-0 items-center justify-center rounded border",
|
|
197
|
+
isAllSelected || isIndeterminate
|
|
198
|
+
? "bg-accent-9 border-accent-9 text-white"
|
|
199
|
+
: "border-gray-7 bg-transparent"
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{isAllSelected && <Check className="size-3" strokeWidth={2.5} />}
|
|
203
|
+
{isIndeterminate && <Minus className="size-3" strokeWidth={2.5} />}
|
|
204
|
+
</span>
|
|
205
|
+
<span className="flex-1">Select All</span>
|
|
206
|
+
</AriaMenuItem>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useContext, useState } from "react"
|
|
4
|
+
import { Button } from "@eggspot/ui/components/Button"
|
|
5
|
+
import {
|
|
6
|
+
Calendar,
|
|
7
|
+
CalendarCell,
|
|
8
|
+
CalendarGrid,
|
|
9
|
+
CalendarGridBody,
|
|
10
|
+
CalendarGridHeader,
|
|
11
|
+
CalendarHeaderCell,
|
|
12
|
+
CalendarHeading,
|
|
13
|
+
} from "@eggspot/ui/components/Calendar"
|
|
14
|
+
import { parse } from "@eggspot/ui/components/DatePicker.utils"
|
|
15
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
16
|
+
import type { CalendarDate, CalendarDateTime } from "@internationalized/date"
|
|
17
|
+
import { getLocalTimeZone, today } from "@internationalized/date"
|
|
18
|
+
import dayjs from "dayjs"
|
|
19
|
+
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
20
|
+
import { RootMenuTriggerStateContext } from "react-aria-components"
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
useFilterContext,
|
|
24
|
+
useFilterItem,
|
|
25
|
+
useFilterItemClose,
|
|
26
|
+
} from "../Filter.store"
|
|
27
|
+
|
|
28
|
+
interface FilterSingleDateProps {
|
|
29
|
+
field?: string
|
|
30
|
+
granularity?: "day" | "month"
|
|
31
|
+
onSelect?: (date: Date | undefined) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FilterSingleDate({
|
|
35
|
+
field: fieldProp,
|
|
36
|
+
granularity = "day",
|
|
37
|
+
onSelect,
|
|
38
|
+
}: FilterSingleDateProps) {
|
|
39
|
+
const { value, setFieldValue } = useFilterContext()
|
|
40
|
+
const filterItem = useFilterItem()
|
|
41
|
+
|
|
42
|
+
const field = fieldProp ?? filterItem?.field
|
|
43
|
+
|
|
44
|
+
if (!field) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"FilterSingleDate requires a field prop or must be used within a FilterItemProvider"
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dateValue = value[field] as Date | null | undefined
|
|
51
|
+
|
|
52
|
+
const handleChange = (date: Date) => {
|
|
53
|
+
if (onSelect) {
|
|
54
|
+
onSelect(date)
|
|
55
|
+
} else {
|
|
56
|
+
setFieldValue(field, date)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleClear = () => {
|
|
61
|
+
if (onSelect) {
|
|
62
|
+
onSelect(undefined)
|
|
63
|
+
} else {
|
|
64
|
+
setFieldValue(field, undefined)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (granularity === "month") {
|
|
69
|
+
return (
|
|
70
|
+
<MonthPicker
|
|
71
|
+
value={dateValue ?? undefined}
|
|
72
|
+
onChange={handleChange}
|
|
73
|
+
onClear={handleClear}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<DayPicker
|
|
80
|
+
value={dateValue ?? undefined}
|
|
81
|
+
onChange={handleChange}
|
|
82
|
+
onClear={handleClear}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface DayPickerProps {
|
|
88
|
+
value?: Date
|
|
89
|
+
onChange: (date: Date) => void
|
|
90
|
+
onClear: () => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function DayPicker({ value, onChange, onClear }: DayPickerProps) {
|
|
94
|
+
const calendarValue = parse(value, "day")
|
|
95
|
+
const todayDate = today(getLocalTimeZone())
|
|
96
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
97
|
+
const onClose = useFilterItemClose()
|
|
98
|
+
|
|
99
|
+
const handleDateChange = (date: CalendarDate | CalendarDateTime) => {
|
|
100
|
+
onChange(date.toDate(getLocalTimeZone()))
|
|
101
|
+
// Close popover after selection
|
|
102
|
+
menuState?.close()
|
|
103
|
+
onClose?.()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleToday = () => {
|
|
107
|
+
onChange(dayjs().startOf("day").toDate())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex h-[352px] min-w-[280px] flex-col p-2">
|
|
112
|
+
<Calendar
|
|
113
|
+
aria-label="Select date"
|
|
114
|
+
className="w-full flex-1"
|
|
115
|
+
value={calendarValue}
|
|
116
|
+
onChange={handleDateChange}
|
|
117
|
+
defaultFocusedValue={calendarValue ?? todayDate}
|
|
118
|
+
>
|
|
119
|
+
<CalendarHeading />
|
|
120
|
+
<CalendarGrid className="w-full">
|
|
121
|
+
<CalendarGridHeader>
|
|
122
|
+
{(day) => (
|
|
123
|
+
<CalendarHeaderCell className="flex-1">{day}</CalendarHeaderCell>
|
|
124
|
+
)}
|
|
125
|
+
</CalendarGridHeader>
|
|
126
|
+
<CalendarGridBody>
|
|
127
|
+
{(date) => <CalendarCell date={date} />}
|
|
128
|
+
</CalendarGridBody>
|
|
129
|
+
</CalendarGrid>
|
|
130
|
+
</Calendar>
|
|
131
|
+
|
|
132
|
+
<div className="border-gray-6 flex justify-center gap-2 border-t pt-2">
|
|
133
|
+
<Button
|
|
134
|
+
size="sm"
|
|
135
|
+
variant="ghost"
|
|
136
|
+
intent="primary"
|
|
137
|
+
onClick={handleToday}
|
|
138
|
+
>
|
|
139
|
+
Today
|
|
140
|
+
</Button>
|
|
141
|
+
<Button
|
|
142
|
+
size="sm"
|
|
143
|
+
variant="ghost"
|
|
144
|
+
intent="secondary"
|
|
145
|
+
onClick={onClear}
|
|
146
|
+
isDisabled={!value}
|
|
147
|
+
>
|
|
148
|
+
Clear
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface MonthPickerProps {
|
|
156
|
+
value?: Date
|
|
157
|
+
onChange: (date: Date) => void
|
|
158
|
+
onClear: () => void
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const MONTHS = [
|
|
162
|
+
"Jan",
|
|
163
|
+
"Feb",
|
|
164
|
+
"Mar",
|
|
165
|
+
"Apr",
|
|
166
|
+
"May",
|
|
167
|
+
"Jun",
|
|
168
|
+
"Jul",
|
|
169
|
+
"Aug",
|
|
170
|
+
"Sep",
|
|
171
|
+
"Oct",
|
|
172
|
+
"Nov",
|
|
173
|
+
"Dec",
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
function MonthPicker({ value, onChange, onClear }: MonthPickerProps) {
|
|
177
|
+
const currentYear = dayjs().year()
|
|
178
|
+
const [viewYear, setViewYear] = useState(
|
|
179
|
+
value ? dayjs(value).year() : currentYear
|
|
180
|
+
)
|
|
181
|
+
const onClose = useFilterItemClose()
|
|
182
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
183
|
+
|
|
184
|
+
const selectedMonth = value ? dayjs(value).month() : null
|
|
185
|
+
const selectedYear = value ? dayjs(value).year() : null
|
|
186
|
+
|
|
187
|
+
const handleMonthSelect = (monthIndex: number) => {
|
|
188
|
+
const date = dayjs()
|
|
189
|
+
.year(viewYear)
|
|
190
|
+
.month(monthIndex)
|
|
191
|
+
.startOf("month")
|
|
192
|
+
.toDate()
|
|
193
|
+
onChange(date)
|
|
194
|
+
onClose?.()
|
|
195
|
+
menuState?.close()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handleThisMonth = () => {
|
|
199
|
+
onChange(dayjs().startOf("month").toDate())
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className="flex min-w-[280px] flex-col p-2">
|
|
204
|
+
<div className="mb-2 flex items-center justify-between">
|
|
205
|
+
<Button
|
|
206
|
+
mode="icon"
|
|
207
|
+
size="sm"
|
|
208
|
+
variant="ghost"
|
|
209
|
+
intent="secondary"
|
|
210
|
+
onClick={() => setViewYear((y) => y - 1)}
|
|
211
|
+
aria-label="Previous year"
|
|
212
|
+
>
|
|
213
|
+
<ChevronLeft className="size-4" />
|
|
214
|
+
</Button>
|
|
215
|
+
<span className="text-gray-12 text-sm font-medium">{viewYear}</span>
|
|
216
|
+
<Button
|
|
217
|
+
mode="icon"
|
|
218
|
+
size="sm"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
intent="secondary"
|
|
221
|
+
onClick={() => setViewYear((y) => y + 1)}
|
|
222
|
+
aria-label="Next year"
|
|
223
|
+
>
|
|
224
|
+
<ChevronRight className="size-4" />
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="grid grid-cols-3 gap-1">
|
|
229
|
+
{MONTHS.map((month, index) => {
|
|
230
|
+
const isSelected =
|
|
231
|
+
selectedMonth === index && selectedYear === viewYear
|
|
232
|
+
const isCurrentMonth =
|
|
233
|
+
dayjs().month() === index && dayjs().year() === viewYear
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
key={month}
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => handleMonthSelect(index)}
|
|
240
|
+
className={cn(
|
|
241
|
+
"rounded-md px-3 py-2 text-sm transition-colors",
|
|
242
|
+
"hover:bg-gray-4 focus-visible:ring-accent-9 focus-visible:ring-2 focus-visible:outline-none",
|
|
243
|
+
isSelected
|
|
244
|
+
? "bg-accent-9 hover:bg-accent-10 text-white"
|
|
245
|
+
: isCurrentMonth
|
|
246
|
+
? "text-accent-11"
|
|
247
|
+
: "text-gray-11 hover:text-gray-12"
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
{month}
|
|
251
|
+
</button>
|
|
252
|
+
)
|
|
253
|
+
})}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="border-gray-6 mt-2 flex justify-center gap-2 border-t pt-2">
|
|
257
|
+
<Button
|
|
258
|
+
size="sm"
|
|
259
|
+
variant="ghost"
|
|
260
|
+
intent="primary"
|
|
261
|
+
onClick={handleThisMonth}
|
|
262
|
+
>
|
|
263
|
+
This month
|
|
264
|
+
</Button>
|
|
265
|
+
<Button
|
|
266
|
+
size="sm"
|
|
267
|
+
variant="ghost"
|
|
268
|
+
intent="secondary"
|
|
269
|
+
onClick={onClear}
|
|
270
|
+
isDisabled={!value}
|
|
271
|
+
>
|
|
272
|
+
Clear
|
|
273
|
+
</Button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
4
|
+
import { Check } from "lucide-react"
|
|
5
|
+
import { MenuItem as AriaMenuItem, ListBoxItem } from "react-aria-components"
|
|
6
|
+
|
|
7
|
+
import type { SelectOption } from "../Filter.types"
|
|
8
|
+
|
|
9
|
+
interface OptionItemProps {
|
|
10
|
+
option: SelectOption
|
|
11
|
+
multi?: boolean
|
|
12
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Shared option item for Menu-based selects (FilterSelect) */
|
|
16
|
+
export function MenuOptionItem({
|
|
17
|
+
option,
|
|
18
|
+
multi = true,
|
|
19
|
+
renderIcon,
|
|
20
|
+
}: OptionItemProps) {
|
|
21
|
+
return (
|
|
22
|
+
<AriaMenuItem
|
|
23
|
+
id={String(option.value)}
|
|
24
|
+
textValue={option.label}
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-1.5 text-left text-sm outline-none",
|
|
27
|
+
"hover:bg-gray-3 data-focused:bg-gray-3 transition-colors",
|
|
28
|
+
"data-selected:text-gray-12 text-gray-11"
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{({ isSelected }) => (
|
|
32
|
+
<OptionItemContent
|
|
33
|
+
option={option}
|
|
34
|
+
isSelected={isSelected}
|
|
35
|
+
multi={multi}
|
|
36
|
+
renderIcon={renderIcon}
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
</AriaMenuItem>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Shared option item for ListBox-based selects (FilterAsyncSelect) */
|
|
44
|
+
export function ListBoxOptionItem({
|
|
45
|
+
option,
|
|
46
|
+
multi = true,
|
|
47
|
+
renderIcon,
|
|
48
|
+
}: OptionItemProps) {
|
|
49
|
+
return (
|
|
50
|
+
<ListBoxItem
|
|
51
|
+
id={String(option.value)}
|
|
52
|
+
textValue={option.label}
|
|
53
|
+
className={cn(
|
|
54
|
+
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-1.5 text-left text-sm outline-none",
|
|
55
|
+
"hover:bg-gray-3 data-focused:bg-gray-3 transition-colors",
|
|
56
|
+
"data-selected:text-gray-12 text-gray-11"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{({ isSelected }) => (
|
|
60
|
+
<OptionItemContent
|
|
61
|
+
option={option}
|
|
62
|
+
isSelected={isSelected}
|
|
63
|
+
multi={multi}
|
|
64
|
+
renderIcon={renderIcon}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</ListBoxItem>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Shared content for option items */
|
|
72
|
+
function OptionItemContent({
|
|
73
|
+
option,
|
|
74
|
+
isSelected,
|
|
75
|
+
multi,
|
|
76
|
+
renderIcon,
|
|
77
|
+
}: {
|
|
78
|
+
option: SelectOption
|
|
79
|
+
isSelected: boolean
|
|
80
|
+
multi?: boolean
|
|
81
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
82
|
+
}) {
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
<span
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex size-4 shrink-0 items-center justify-center rounded border",
|
|
88
|
+
multi ? "rounded" : "rounded-full",
|
|
89
|
+
isSelected
|
|
90
|
+
? "bg-accent-9 border-accent-9 text-white"
|
|
91
|
+
: "border-gray-7 bg-transparent"
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{isSelected && <Check className="size-3" strokeWidth={2.5} />}
|
|
95
|
+
</span>
|
|
96
|
+
|
|
97
|
+
{renderIcon ? (
|
|
98
|
+
<span className="shrink-0">{renderIcon(option, isSelected)}</span>
|
|
99
|
+
) : option.avatar ? (
|
|
100
|
+
<img
|
|
101
|
+
src={option.avatar}
|
|
102
|
+
alt={option.label}
|
|
103
|
+
className="size-5 shrink-0 rounded-full object-cover"
|
|
104
|
+
/>
|
|
105
|
+
) : option.icon ? (
|
|
106
|
+
<span className="text-gray-11 shrink-0">{option.icon}</span>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|