@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,191 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useContext, useMemo } from "react"
|
|
4
|
+
import { SearchInput } from "@eggspot/ui/components/Input"
|
|
5
|
+
import {
|
|
6
|
+
Autocomplete,
|
|
7
|
+
Collection,
|
|
8
|
+
ListBox,
|
|
9
|
+
ListBoxLoadMoreItem,
|
|
10
|
+
ListLayout,
|
|
11
|
+
RootMenuTriggerStateContext,
|
|
12
|
+
Virtualizer,
|
|
13
|
+
} from "react-aria-components"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useFilterContext,
|
|
17
|
+
useFilterItem,
|
|
18
|
+
useFilterItemClose,
|
|
19
|
+
} from "../Filter.store"
|
|
20
|
+
import type {
|
|
21
|
+
OptionsLoader,
|
|
22
|
+
SelectOption,
|
|
23
|
+
SerializableSelectOption,
|
|
24
|
+
} from "../Filter.types"
|
|
25
|
+
import { isSelectOptionArray } from "../Filter.types"
|
|
26
|
+
import { ListBoxOptionItem } from "./OptionItem"
|
|
27
|
+
|
|
28
|
+
interface FilterAsyncSelectProps {
|
|
29
|
+
field?: string
|
|
30
|
+
options: SelectOption[]
|
|
31
|
+
multi?: boolean
|
|
32
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
33
|
+
placeholder?: string
|
|
34
|
+
emptyMessage?: string
|
|
35
|
+
optionsLoader: OptionsLoader
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FilterAsyncSelect({
|
|
39
|
+
field: fieldProp,
|
|
40
|
+
options,
|
|
41
|
+
multi = true,
|
|
42
|
+
renderIcon,
|
|
43
|
+
placeholder = "Search...",
|
|
44
|
+
emptyMessage = "No matching options",
|
|
45
|
+
optionsLoader,
|
|
46
|
+
}: FilterAsyncSelectProps) {
|
|
47
|
+
const { value: filters, setFieldValue } = useFilterContext()
|
|
48
|
+
const filterItem = useFilterItem()
|
|
49
|
+
const onClose = useFilterItemClose()
|
|
50
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
51
|
+
|
|
52
|
+
const field = fieldProp ?? filterItem?.field
|
|
53
|
+
|
|
54
|
+
if (!field) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"FilterAsyncSelect requires a field prop or must be used within a FilterItemProvider"
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const raw = filters[field]
|
|
61
|
+
|
|
62
|
+
const storedOptions = useMemo(() => {
|
|
63
|
+
if (isSelectOptionArray(raw)) return raw
|
|
64
|
+
return []
|
|
65
|
+
}, [raw])
|
|
66
|
+
|
|
67
|
+
const selectedValues = useMemo(() => {
|
|
68
|
+
if (!raw) return []
|
|
69
|
+
if (isSelectOptionArray(raw)) {
|
|
70
|
+
return raw.map((opt) => opt.value)
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
return raw.map(String)
|
|
74
|
+
}
|
|
75
|
+
return [String(raw)]
|
|
76
|
+
}, [raw])
|
|
77
|
+
|
|
78
|
+
const selectedKeys = useMemo(() => new Set(selectedValues), [selectedValues])
|
|
79
|
+
|
|
80
|
+
const optionsWithId = useMemo(
|
|
81
|
+
() => options.map((opt) => ({ ...opt, id: String(opt.value) })),
|
|
82
|
+
[options]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const handleSelectionChange = useCallback(
|
|
86
|
+
(keys: Set<React.Key>) => {
|
|
87
|
+
const selectedIds = Array.from(keys).map(String)
|
|
88
|
+
|
|
89
|
+
const allAvailableOptions = new Map<string, SerializableSelectOption>()
|
|
90
|
+
|
|
91
|
+
storedOptions.forEach((opt) => {
|
|
92
|
+
allAvailableOptions.set(opt.value, opt)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
options.forEach((opt) => {
|
|
96
|
+
const { icon, ...rest } = opt
|
|
97
|
+
allAvailableOptions.set(String(opt.value), rest)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const selectedOptions = selectedIds
|
|
101
|
+
.map((id) => allAvailableOptions.get(id))
|
|
102
|
+
.filter((opt): opt is SerializableSelectOption => opt !== undefined)
|
|
103
|
+
|
|
104
|
+
setFieldValue(
|
|
105
|
+
field,
|
|
106
|
+
selectedOptions.length > 0 ? selectedOptions : undefined
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (!multi && selectedOptions.length > 0) {
|
|
110
|
+
menuState?.close()
|
|
111
|
+
onClose?.()
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
[field, options, storedOptions, setFieldValue, multi, onClose]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const isInitialLoading = optionsLoader.isFetching && options.length === 0
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex max-h-[300px] min-w-[200px] flex-col">
|
|
121
|
+
<Autocomplete disableVirtualFocus>
|
|
122
|
+
<div className="border-gray-6 border-b px-3 py-2">
|
|
123
|
+
<SearchInput
|
|
124
|
+
autoFocus
|
|
125
|
+
placeholder={placeholder}
|
|
126
|
+
className="placeholder:text-gray-11 h-7 rounded border-0 bg-transparent pl-0 text-xs ring-0!"
|
|
127
|
+
onChange={optionsLoader.onSearch}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{isInitialLoading ? (
|
|
132
|
+
<div className="py-1">
|
|
133
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
134
|
+
<SkeletonOption key={i} />
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
<Virtualizer
|
|
139
|
+
layout={ListLayout}
|
|
140
|
+
layoutOptions={{ estimatedRowHeight: 32 }}
|
|
141
|
+
>
|
|
142
|
+
<ListBox
|
|
143
|
+
aria-label="Filter options"
|
|
144
|
+
selectionMode={multi ? "multiple" : "single"}
|
|
145
|
+
selectedKeys={selectedKeys}
|
|
146
|
+
onSelectionChange={(keys) =>
|
|
147
|
+
handleSelectionChange(keys as Set<string>)
|
|
148
|
+
}
|
|
149
|
+
className="max-h-[250px] flex-1 overflow-auto py-1 outline-none"
|
|
150
|
+
renderEmptyState={() => (
|
|
151
|
+
<div className="text-gray-11 px-3 py-6 text-center text-xs">
|
|
152
|
+
{emptyMessage}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
<Collection items={optionsWithId}>
|
|
157
|
+
{(option) => (
|
|
158
|
+
<ListBoxOptionItem
|
|
159
|
+
option={option}
|
|
160
|
+
multi={multi}
|
|
161
|
+
renderIcon={renderIcon}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</Collection>
|
|
165
|
+
|
|
166
|
+
{optionsLoader.hasNextPage && (
|
|
167
|
+
<ListBoxLoadMoreItem
|
|
168
|
+
onLoadMore={optionsLoader.onFetchNextPage}
|
|
169
|
+
isLoading={optionsLoader.isFetching}
|
|
170
|
+
className="py-1"
|
|
171
|
+
>
|
|
172
|
+
<SkeletonOption />
|
|
173
|
+
<SkeletonOption />
|
|
174
|
+
</ListBoxLoadMoreItem>
|
|
175
|
+
)}
|
|
176
|
+
</ListBox>
|
|
177
|
+
</Virtualizer>
|
|
178
|
+
)}
|
|
179
|
+
</Autocomplete>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function SkeletonOption() {
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex h-8 animate-pulse items-center gap-2.5 px-3 py-1.5">
|
|
187
|
+
<div className="border-gray-6 bg-gray-4 size-4 shrink-0 rounded border" />
|
|
188
|
+
<div className="bg-gray-4 h-4 max-w-[120px] flex-1 rounded" />
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useContext, useEffect, useRef } from "react"
|
|
4
|
+
import {
|
|
5
|
+
Menu,
|
|
6
|
+
MenuItem,
|
|
7
|
+
MenuPopover,
|
|
8
|
+
MenuSeparator,
|
|
9
|
+
MenuSubTrigger,
|
|
10
|
+
} from "@eggspot/ui/components/Menu"
|
|
11
|
+
import dayjs from "dayjs"
|
|
12
|
+
import { RootMenuTriggerStateContext } from "react-aria-components"
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
useFilterContext,
|
|
16
|
+
useFilterItem,
|
|
17
|
+
useFilterItemClose,
|
|
18
|
+
} from "../Filter.store"
|
|
19
|
+
import type { DateModeFilterValue } from "../Filter.types"
|
|
20
|
+
import { FilterDateRange } from "./FilterDateRange"
|
|
21
|
+
import { FilterSingleDate } from "./FilterSingleDate"
|
|
22
|
+
|
|
23
|
+
function normalizeValue(value: unknown): DateModeFilterValue | null {
|
|
24
|
+
if (value == null) return null
|
|
25
|
+
|
|
26
|
+
if (typeof value === "object" && !Array.isArray(value) && "mode" in value) {
|
|
27
|
+
const v = value as DateModeFilterValue
|
|
28
|
+
return {
|
|
29
|
+
...v,
|
|
30
|
+
from: v.from ? new Date(v.from) : undefined,
|
|
31
|
+
to: v.to ? new Date(v.to) : undefined,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
36
|
+
const [fromRaw, toRaw] = value
|
|
37
|
+
return {
|
|
38
|
+
mode: "range",
|
|
39
|
+
from: fromRaw ? new Date(fromRaw as any) : undefined,
|
|
40
|
+
to: toRaw ? new Date(toRaw as any) : undefined,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function FilterDateMode() {
|
|
48
|
+
const { value, setFieldValue } = useFilterContext()
|
|
49
|
+
const item = useFilterItem()
|
|
50
|
+
const field = item?.field
|
|
51
|
+
|
|
52
|
+
if (!field) {
|
|
53
|
+
throw new Error("FilterDateMode must be used within a FilterItemProvider")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const current = normalizeValue(value[field])
|
|
57
|
+
const today = dayjs()
|
|
58
|
+
const todayEnd = today.endOf("day").toDate()
|
|
59
|
+
const todayStart = today.startOf("day").toDate()
|
|
60
|
+
|
|
61
|
+
const updateValue = (newValue: DateModeFilterValue | undefined) => {
|
|
62
|
+
setFieldValue(field, newValue)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const getDateForSingleDate = (
|
|
66
|
+
targetMode: "from" | "to"
|
|
67
|
+
): Date | undefined => {
|
|
68
|
+
if (!current) return undefined
|
|
69
|
+
if (current.mode === targetMode) {
|
|
70
|
+
return targetMode === "from" ? current.from : current.to
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const getRangeForDateRange = (): [Date, Date] | undefined => {
|
|
76
|
+
if (!current) return undefined
|
|
77
|
+
if (current.mode === "range" && current.from && current.to) {
|
|
78
|
+
return [current.from, current.to]
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const tempFieldFrom = `${field}__temp_from`
|
|
84
|
+
const tempFieldTo = `${field}__temp_to`
|
|
85
|
+
const tempFieldRange = `${field}__temp_range`
|
|
86
|
+
|
|
87
|
+
const prevCurrentRef = useRef<DateModeFilterValue | null>(null)
|
|
88
|
+
|
|
89
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
90
|
+
const onClose = useFilterItemClose()
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const currentChanged =
|
|
94
|
+
(!current && prevCurrentRef.current) ||
|
|
95
|
+
(current && !prevCurrentRef.current) ||
|
|
96
|
+
(current &&
|
|
97
|
+
prevCurrentRef.current &&
|
|
98
|
+
(current.mode !== prevCurrentRef.current.mode ||
|
|
99
|
+
current.from?.getTime() !== prevCurrentRef.current.from?.getTime() ||
|
|
100
|
+
current.to?.getTime() !== prevCurrentRef.current.to?.getTime()))
|
|
101
|
+
|
|
102
|
+
if (currentChanged) {
|
|
103
|
+
prevCurrentRef.current = current
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dateForFrom = getDateForSingleDate("from")
|
|
107
|
+
const dateForTo = getDateForSingleDate("to")
|
|
108
|
+
const rangeForRange = getRangeForDateRange()
|
|
109
|
+
const currentFrom = value[tempFieldFrom] as Date | undefined
|
|
110
|
+
const currentTo = value[tempFieldTo] as Date | undefined
|
|
111
|
+
const currentRange = value[tempFieldRange] as [Date, Date] | undefined
|
|
112
|
+
|
|
113
|
+
if (dateForFrom?.getTime() !== currentFrom?.getTime()) {
|
|
114
|
+
setFieldValue(tempFieldFrom, dateForFrom)
|
|
115
|
+
}
|
|
116
|
+
if (dateForTo?.getTime() !== currentTo?.getTime()) {
|
|
117
|
+
setFieldValue(tempFieldTo, dateForTo)
|
|
118
|
+
}
|
|
119
|
+
const rangeChanged =
|
|
120
|
+
(!rangeForRange && currentRange) ||
|
|
121
|
+
(rangeForRange && !currentRange) ||
|
|
122
|
+
(rangeForRange &&
|
|
123
|
+
currentRange &&
|
|
124
|
+
(rangeForRange[0]?.getTime() !== currentRange[0]?.getTime() ||
|
|
125
|
+
rangeForRange[1]?.getTime() !== currentRange[1]?.getTime()))
|
|
126
|
+
if (rangeChanged) {
|
|
127
|
+
setFieldValue(tempFieldRange, rangeForRange)
|
|
128
|
+
}
|
|
129
|
+
}, [
|
|
130
|
+
current,
|
|
131
|
+
tempFieldFrom,
|
|
132
|
+
tempFieldTo,
|
|
133
|
+
tempFieldRange,
|
|
134
|
+
setFieldValue,
|
|
135
|
+
value,
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
const applyPreset = (preset: "lastWeek" | "thisWeek" | "thisMonth") => {
|
|
139
|
+
let start: dayjs.Dayjs
|
|
140
|
+
let end: dayjs.Dayjs
|
|
141
|
+
|
|
142
|
+
switch (preset) {
|
|
143
|
+
case "lastWeek":
|
|
144
|
+
start = today.startOf("week").subtract(1, "week")
|
|
145
|
+
end = start.endOf("week")
|
|
146
|
+
break
|
|
147
|
+
case "thisWeek":
|
|
148
|
+
start = today.startOf("week")
|
|
149
|
+
end = today.endOf("week")
|
|
150
|
+
break
|
|
151
|
+
case "thisMonth":
|
|
152
|
+
start = today.startOf("month")
|
|
153
|
+
end = today.endOf("month")
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
updateValue({
|
|
158
|
+
mode: "range",
|
|
159
|
+
from: start.startOf("day").toDate(),
|
|
160
|
+
to: end.endOf("day").toDate(),
|
|
161
|
+
preset,
|
|
162
|
+
})
|
|
163
|
+
menuState?.close()
|
|
164
|
+
onClose?.()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Menu aria-label="Date filter options" className="min-w-[220px]">
|
|
169
|
+
<MenuSubTrigger>
|
|
170
|
+
<MenuItem>From date</MenuItem>
|
|
171
|
+
<MenuPopover className="p-0">
|
|
172
|
+
<FilterSingleDate
|
|
173
|
+
field={tempFieldFrom}
|
|
174
|
+
onSelect={(date) => {
|
|
175
|
+
if (!date) {
|
|
176
|
+
updateValue(undefined)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const fromDate = dayjs(date).startOf("day").toDate()
|
|
180
|
+
updateValue({
|
|
181
|
+
mode: "from",
|
|
182
|
+
from: fromDate,
|
|
183
|
+
to: undefined,
|
|
184
|
+
})
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
</MenuPopover>
|
|
188
|
+
</MenuSubTrigger>
|
|
189
|
+
|
|
190
|
+
<MenuSubTrigger>
|
|
191
|
+
<MenuItem>To date</MenuItem>
|
|
192
|
+
<MenuPopover className="p-0">
|
|
193
|
+
<FilterSingleDate
|
|
194
|
+
field={tempFieldTo}
|
|
195
|
+
onSelect={(date) => {
|
|
196
|
+
if (!date) {
|
|
197
|
+
updateValue(undefined)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const toDate = dayjs(date).endOf("day").toDate()
|
|
201
|
+
updateValue({
|
|
202
|
+
mode: "to",
|
|
203
|
+
from: undefined,
|
|
204
|
+
to: toDate,
|
|
205
|
+
})
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
</MenuPopover>
|
|
209
|
+
</MenuSubTrigger>
|
|
210
|
+
|
|
211
|
+
<MenuSubTrigger>
|
|
212
|
+
<MenuItem>Custom date</MenuItem>
|
|
213
|
+
<MenuPopover className="p-0">
|
|
214
|
+
<FilterDateRange
|
|
215
|
+
field={tempFieldRange}
|
|
216
|
+
onSelectRange={(range) => {
|
|
217
|
+
if (!range) {
|
|
218
|
+
updateValue(undefined)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
updateValue({
|
|
222
|
+
mode: "range",
|
|
223
|
+
from: range[0],
|
|
224
|
+
to: range[1],
|
|
225
|
+
})
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
</MenuPopover>
|
|
229
|
+
</MenuSubTrigger>
|
|
230
|
+
|
|
231
|
+
<MenuSeparator />
|
|
232
|
+
|
|
233
|
+
{/* PRESETS */}
|
|
234
|
+
<MenuItem onAction={() => applyPreset("lastWeek")}>Last week</MenuItem>
|
|
235
|
+
|
|
236
|
+
<MenuItem onAction={() => applyPreset("thisWeek")}>This week</MenuItem>
|
|
237
|
+
|
|
238
|
+
<MenuItem onAction={() => applyPreset("thisMonth")}>This month</MenuItem>
|
|
239
|
+
</Menu>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useContext, useState } from "react"
|
|
4
|
+
import { Button } from "@eggspot/ui/components/Button"
|
|
5
|
+
import {
|
|
6
|
+
CalendarCell,
|
|
7
|
+
CalendarGrid,
|
|
8
|
+
CalendarGridBody,
|
|
9
|
+
CalendarGridHeader,
|
|
10
|
+
CalendarHeaderCell,
|
|
11
|
+
CalendarHeading,
|
|
12
|
+
RangeCalendar,
|
|
13
|
+
} from "@eggspot/ui/components/Calendar"
|
|
14
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
15
|
+
import {
|
|
16
|
+
fromDate,
|
|
17
|
+
getLocalTimeZone,
|
|
18
|
+
today,
|
|
19
|
+
type CalendarDate,
|
|
20
|
+
} from "@internationalized/date"
|
|
21
|
+
import dayjs from "dayjs"
|
|
22
|
+
import {
|
|
23
|
+
RootMenuTriggerStateContext,
|
|
24
|
+
type DateRange,
|
|
25
|
+
} from "react-aria-components"
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
useFilterContext,
|
|
29
|
+
useFilterItem,
|
|
30
|
+
useFilterItemClose,
|
|
31
|
+
} from "../Filter.store"
|
|
32
|
+
|
|
33
|
+
interface FilterDateRangeProps {
|
|
34
|
+
field?: string
|
|
35
|
+
onSelectRange?: (range: [Date, Date] | undefined) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FilterDateRange({
|
|
39
|
+
field: fieldProp,
|
|
40
|
+
onSelectRange,
|
|
41
|
+
}: FilterDateRangeProps) {
|
|
42
|
+
const { value, setFieldValue } = useFilterContext()
|
|
43
|
+
const filterItem = useFilterItem()
|
|
44
|
+
const onClose = useFilterItemClose()
|
|
45
|
+
const menuState = useContext(RootMenuTriggerStateContext)
|
|
46
|
+
const field = fieldProp ?? filterItem?.field
|
|
47
|
+
if (!field) throw new Error("...")
|
|
48
|
+
|
|
49
|
+
const raw = value[field] as [Date, Date] | null | undefined
|
|
50
|
+
const [startDate, endDate] = raw ?? []
|
|
51
|
+
|
|
52
|
+
const tz = getLocalTimeZone()
|
|
53
|
+
const todayDate = today(tz)
|
|
54
|
+
|
|
55
|
+
const initialStart = startDate ? fromDate(startDate, tz) : undefined
|
|
56
|
+
const initialEnd = endDate ? fromDate(endDate, tz) : undefined
|
|
57
|
+
|
|
58
|
+
const [draftRange, setDraftRange] = useState<DateRange | null>(
|
|
59
|
+
initialStart && initialEnd ? { start: initialStart, end: initialEnd } : null
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const calendarValue = draftRange ?? null
|
|
63
|
+
|
|
64
|
+
const applyRange = (range: [Date, Date] | undefined) => {
|
|
65
|
+
if (onSelectRange) {
|
|
66
|
+
onSelectRange(range)
|
|
67
|
+
} else {
|
|
68
|
+
setFieldValue(field, range)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const commitDraft = () => {
|
|
73
|
+
if (!draftRange?.start || !draftRange?.end) {
|
|
74
|
+
applyRange(undefined)
|
|
75
|
+
onClose?.()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const start = draftRange.start.toDate(tz)
|
|
79
|
+
const end = draftRange.end.toDate(tz)
|
|
80
|
+
applyRange([
|
|
81
|
+
dayjs(start).startOf("day").toDate(),
|
|
82
|
+
dayjs(end).endOf("day").toDate(),
|
|
83
|
+
])
|
|
84
|
+
menuState?.close()
|
|
85
|
+
onClose?.()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handleClear = () => {
|
|
89
|
+
setDraftRange(null)
|
|
90
|
+
applyRange(undefined)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex h-[352px] min-w-[280px] flex-col p-2">
|
|
95
|
+
<RangeCalendar
|
|
96
|
+
aria-label="Select date range"
|
|
97
|
+
className="w-full flex-1"
|
|
98
|
+
value={calendarValue}
|
|
99
|
+
onChange={setDraftRange}
|
|
100
|
+
defaultFocusedValue={calendarValue?.start ?? todayDate}
|
|
101
|
+
>
|
|
102
|
+
<CalendarHeading />
|
|
103
|
+
<CalendarGrid className="border-spacing-x-0 border-spacing-y-0.5">
|
|
104
|
+
<CalendarGridHeader>
|
|
105
|
+
{(day) => (
|
|
106
|
+
<CalendarHeaderCell className="text-center">
|
|
107
|
+
{day}
|
|
108
|
+
</CalendarHeaderCell>
|
|
109
|
+
)}
|
|
110
|
+
</CalendarGridHeader>
|
|
111
|
+
<CalendarGridBody>
|
|
112
|
+
{(date) => (
|
|
113
|
+
<RangeCalendarCell
|
|
114
|
+
date={date as CalendarDate}
|
|
115
|
+
range={draftRange}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</CalendarGridBody>
|
|
119
|
+
</CalendarGrid>
|
|
120
|
+
</RangeCalendar>
|
|
121
|
+
|
|
122
|
+
<div className="border-gray-6 flex justify-center gap-2 border-t pt-2">
|
|
123
|
+
<Button
|
|
124
|
+
size="sm"
|
|
125
|
+
variant="ghost"
|
|
126
|
+
intent="primary"
|
|
127
|
+
onClick={commitDraft}
|
|
128
|
+
isDisabled={!draftRange}
|
|
129
|
+
>
|
|
130
|
+
Apply
|
|
131
|
+
</Button>
|
|
132
|
+
<Button
|
|
133
|
+
size="sm"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
intent="secondary"
|
|
136
|
+
onClick={handleClear}
|
|
137
|
+
isDisabled={!raw}
|
|
138
|
+
className="ml-2"
|
|
139
|
+
>
|
|
140
|
+
Clear
|
|
141
|
+
</Button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function RangeCalendarCell({
|
|
148
|
+
date,
|
|
149
|
+
range,
|
|
150
|
+
}: {
|
|
151
|
+
date: CalendarDate
|
|
152
|
+
range: DateRange | null
|
|
153
|
+
}) {
|
|
154
|
+
const hasRange =
|
|
155
|
+
!!range?.start && !!range?.end && range.start.compare(range.end) !== 0
|
|
156
|
+
return (
|
|
157
|
+
<CalendarCell
|
|
158
|
+
date={date}
|
|
159
|
+
className={cn(
|
|
160
|
+
"data-selected:bg-accent-4 data-selected:text-accent-12 data-selected:rounded-none",
|
|
161
|
+
"data-selection-start:rounded-l-md",
|
|
162
|
+
"data-selection-end:rounded-r-md",
|
|
163
|
+
"data-selection-start:data-selection-end:rounded-md",
|
|
164
|
+
"data-selected:data-selection-start:bg-accent-9 data-selected:data-selection-start:text-accent-contrast",
|
|
165
|
+
"data-selected:data-selection-end:bg-accent-9 data-selected:data-selection-end:text-accent-contrast"
|
|
166
|
+
)}
|
|
167
|
+
/>
|
|
168
|
+
)
|
|
169
|
+
}
|