@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,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
type PropsWithChildren,
|
|
7
|
+
} from "react"
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
FilterBuilderItem,
|
|
11
|
+
FilterContextValue,
|
|
12
|
+
FilterFieldValue,
|
|
13
|
+
FilterProps,
|
|
14
|
+
FilterRenderMode,
|
|
15
|
+
FilterValue,
|
|
16
|
+
} from "./Filter.types"
|
|
17
|
+
|
|
18
|
+
const FilterContext = createContext<FilterContextValue | undefined>(undefined)
|
|
19
|
+
|
|
20
|
+
export function useFilterContext() {
|
|
21
|
+
const ctx = useContext(FilterContext)
|
|
22
|
+
if (!ctx) {
|
|
23
|
+
throw new Error("Filter components must be used within <Filter>")
|
|
24
|
+
}
|
|
25
|
+
return ctx
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FilterProvider({
|
|
29
|
+
value: controlledValue,
|
|
30
|
+
defaultValue,
|
|
31
|
+
onChange,
|
|
32
|
+
children,
|
|
33
|
+
}: PropsWithChildren<FilterProps>) {
|
|
34
|
+
const isControlled = controlledValue !== undefined
|
|
35
|
+
|
|
36
|
+
const [uncontrolledValue, setUncontrolledValue] = useState<FilterValue>(
|
|
37
|
+
defaultValue ?? {}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const value = isControlled ? controlledValue! : uncontrolledValue
|
|
41
|
+
|
|
42
|
+
const setValue = (next: FilterValue) => {
|
|
43
|
+
if (!isControlled) setUncontrolledValue(next)
|
|
44
|
+
onChange?.(next)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const setFieldValue = (field: string, fieldValue: FilterFieldValue) => {
|
|
48
|
+
setValue({
|
|
49
|
+
...value,
|
|
50
|
+
[field]: fieldValue,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const clearField = (field: string) => {
|
|
55
|
+
if (!(field in value)) return
|
|
56
|
+
const { [field]: _removed, ...rest } = value
|
|
57
|
+
setValue(rest)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const reset = () => {
|
|
61
|
+
setValue({})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ctxValue = useMemo<FilterContextValue>(
|
|
65
|
+
() => ({
|
|
66
|
+
value,
|
|
67
|
+
onChange: setValue,
|
|
68
|
+
setFieldValue,
|
|
69
|
+
clearField,
|
|
70
|
+
reset,
|
|
71
|
+
}),
|
|
72
|
+
[value]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<FilterContext.Provider value={ctxValue}>{children}</FilterContext.Provider>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Context for the current filter item being rendered. */
|
|
81
|
+
interface FilterItemContextValue {
|
|
82
|
+
item: FilterBuilderItem
|
|
83
|
+
mode: FilterRenderMode
|
|
84
|
+
onClose?: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const FilterItemContext = createContext<FilterItemContextValue | undefined>(
|
|
88
|
+
undefined
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
export function FilterItemProvider({
|
|
92
|
+
item,
|
|
93
|
+
mode,
|
|
94
|
+
onClose,
|
|
95
|
+
children,
|
|
96
|
+
}: PropsWithChildren<{
|
|
97
|
+
item: FilterBuilderItem
|
|
98
|
+
mode: FilterRenderMode
|
|
99
|
+
onClose?: () => void
|
|
100
|
+
}>) {
|
|
101
|
+
const value = useMemo(() => ({ item, mode, onClose }), [item, mode, onClose])
|
|
102
|
+
return (
|
|
103
|
+
<FilterItemContext.Provider value={value}>
|
|
104
|
+
{children}
|
|
105
|
+
</FilterItemContext.Provider>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function useFilterItem() {
|
|
110
|
+
const ctx = useContext(FilterItemContext)
|
|
111
|
+
return ctx?.item
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function useFilterItemMode() {
|
|
115
|
+
const ctx = useContext(FilterItemContext)
|
|
116
|
+
return ctx?.mode
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useFilterItemClose() {
|
|
120
|
+
const ctx = useContext(FilterItemContext)
|
|
121
|
+
return ctx?.onClose
|
|
122
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { PropsWithChildren } from "react"
|
|
4
|
+
|
|
5
|
+
import { FilterProvider } from "./Filter.store"
|
|
6
|
+
import type { FilterProps } from "./Filter.types"
|
|
7
|
+
|
|
8
|
+
export function Filter(props: PropsWithChildren<FilterProps>) {
|
|
9
|
+
const { children, ...providerProps } = props
|
|
10
|
+
return <FilterProvider {...providerProps}>{children}</FilterProvider>
|
|
11
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
export type FilterPrimitive =
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| null
|
|
8
|
+
| undefined
|
|
9
|
+
| Date
|
|
10
|
+
| [Date, Date]
|
|
11
|
+
| (string | number)[]
|
|
12
|
+
|
|
13
|
+
export type SelectOption = {
|
|
14
|
+
label: string
|
|
15
|
+
value: string
|
|
16
|
+
avatar?: string
|
|
17
|
+
icon?: React.ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SerializableSelectOption = Omit<SelectOption, "icon">
|
|
21
|
+
|
|
22
|
+
export type DateFilterMode = "from" | "to" | "range"
|
|
23
|
+
export type DateFilterPreset = "lastWeek" | "thisWeek" | "thisMonth"
|
|
24
|
+
|
|
25
|
+
export interface DateModeFilterValue {
|
|
26
|
+
mode: DateFilterMode
|
|
27
|
+
from?: Date
|
|
28
|
+
to?: Date
|
|
29
|
+
preset?: DateFilterPreset
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FilterFieldValue =
|
|
33
|
+
| FilterPrimitive
|
|
34
|
+
| FilterPrimitive[]
|
|
35
|
+
| SerializableSelectOption[]
|
|
36
|
+
| DateModeFilterValue
|
|
37
|
+
|
|
38
|
+
export type FilterValue = Record<string, FilterFieldValue>
|
|
39
|
+
|
|
40
|
+
export interface FilterContextValue {
|
|
41
|
+
value: FilterValue
|
|
42
|
+
onChange: (value: FilterValue) => void
|
|
43
|
+
setFieldValue: (field: string, v: FilterFieldValue) => void
|
|
44
|
+
clearField: (field: string) => void
|
|
45
|
+
reset: () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FilterProps {
|
|
49
|
+
value?: FilterValue
|
|
50
|
+
defaultValue?: FilterValue
|
|
51
|
+
onChange?: (value: FilterValue) => void
|
|
52
|
+
children: ReactNode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OptionsLoader {
|
|
56
|
+
hasNextPage: boolean
|
|
57
|
+
onFetchNextPage: () => void
|
|
58
|
+
isFetching?: boolean
|
|
59
|
+
onSearch?: (search: string) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type FilterRenderMode = "builder" | "row"
|
|
63
|
+
|
|
64
|
+
export interface FilterBuilderItem {
|
|
65
|
+
type?: "filter"
|
|
66
|
+
field: string
|
|
67
|
+
label: ReactNode
|
|
68
|
+
icon?: ReactNode
|
|
69
|
+
description?: ReactNode
|
|
70
|
+
render: (mode: FilterRenderMode) => ReactNode
|
|
71
|
+
options?: SelectOption[]
|
|
72
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => ReactNode
|
|
73
|
+
multi?: boolean
|
|
74
|
+
formatValue?: (value: unknown) => string
|
|
75
|
+
isDisabled?: boolean
|
|
76
|
+
renderRowValue?: (args: { value: unknown; field: string }) => React.ReactNode
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FilterBuilderSeparator {
|
|
80
|
+
type: "separator"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type FilterBuilderEntry = FilterBuilderItem | FilterBuilderSeparator
|
|
84
|
+
|
|
85
|
+
export interface FilterBuilderProps {
|
|
86
|
+
items: FilterBuilderEntry[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isFilterItem(
|
|
90
|
+
entry: FilterBuilderEntry
|
|
91
|
+
): entry is FilterBuilderItem {
|
|
92
|
+
return entry.type !== "separator"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isSelectOptionArray(
|
|
96
|
+
value: unknown
|
|
97
|
+
): value is SerializableSelectOption[] {
|
|
98
|
+
if (!Array.isArray(value)) return false
|
|
99
|
+
if (value.length === 0) return false
|
|
100
|
+
const first = value[0]
|
|
101
|
+
return (
|
|
102
|
+
typeof first === "object" &&
|
|
103
|
+
first !== null &&
|
|
104
|
+
"value" in first &&
|
|
105
|
+
"label" in first
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
import { useFilterContext } from "./Filter.store"
|
|
6
|
+
import type { FilterBuilderEntry } from "./Filter.types"
|
|
7
|
+
import { isFilterItem } from "./Filter.types"
|
|
8
|
+
import { FilterBuilder } from "./FilterBuilder"
|
|
9
|
+
import { FilterRow } from "./FilterRow"
|
|
10
|
+
|
|
11
|
+
interface FilterBarProps {
|
|
12
|
+
items: FilterBuilderEntry[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FilterBar({ items }: FilterBarProps) {
|
|
16
|
+
const { value, clearField } = useFilterContext()
|
|
17
|
+
|
|
18
|
+
// Auto-clear values for disabled filters
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
for (const entry of items) {
|
|
21
|
+
if (!isFilterItem(entry)) continue
|
|
22
|
+
if (entry.isDisabled && value[entry.field] != null) {
|
|
23
|
+
clearField(entry.field)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}, [items, value, clearField])
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
30
|
+
{items.map((entry) => {
|
|
31
|
+
if (!isFilterItem(entry)) return null
|
|
32
|
+
if (value[entry.field] == null) return null
|
|
33
|
+
return <FilterRow key={entry.field} item={entry} />
|
|
34
|
+
})}
|
|
35
|
+
<FilterBuilder items={items} />
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
4
|
+
import { Button } from "@eggspot/ui/components/Button"
|
|
5
|
+
import { SearchInput } from "@eggspot/ui/components/Input"
|
|
6
|
+
import {
|
|
7
|
+
Menu,
|
|
8
|
+
MenuHeader,
|
|
9
|
+
MenuItem,
|
|
10
|
+
MenuPopover,
|
|
11
|
+
MenuSeparator,
|
|
12
|
+
MenuSubTrigger,
|
|
13
|
+
MenuTrigger,
|
|
14
|
+
} from "@eggspot/ui/components/Menu"
|
|
15
|
+
import { ListFilter as FilterIcon } from "lucide-react"
|
|
16
|
+
|
|
17
|
+
import { FilterItemProvider, useFilterContext } from "./Filter.store"
|
|
18
|
+
import type { FilterBuilderItem, FilterBuilderProps } from "./Filter.types"
|
|
19
|
+
import { isFilterItem } from "./Filter.types"
|
|
20
|
+
|
|
21
|
+
export function FilterBuilder({ items }: FilterBuilderProps) {
|
|
22
|
+
const { value: filters } = useFilterContext()
|
|
23
|
+
const [search, setSearch] = useState("")
|
|
24
|
+
|
|
25
|
+
const activeFilterCount = Object.keys(filters).filter(
|
|
26
|
+
(key) => filters[key] != null
|
|
27
|
+
).length
|
|
28
|
+
const prevCountRef = useRef(activeFilterCount)
|
|
29
|
+
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
if (prevCountRef.current !== activeFilterCount) {
|
|
32
|
+
prevCountRef.current = activeFilterCount
|
|
33
|
+
window.dispatchEvent(new Event("resize"))
|
|
34
|
+
}
|
|
35
|
+
}, [activeFilterCount])
|
|
36
|
+
|
|
37
|
+
const visibleItems = useMemo(
|
|
38
|
+
() =>
|
|
39
|
+
items.filter((entry) => {
|
|
40
|
+
if (!isFilterItem(entry)) return !search
|
|
41
|
+
if (!search) return true
|
|
42
|
+
const text =
|
|
43
|
+
typeof entry.label === "string"
|
|
44
|
+
? entry.label.toLowerCase()
|
|
45
|
+
: entry.field.toLowerCase()
|
|
46
|
+
return text.includes(search.toLowerCase())
|
|
47
|
+
}),
|
|
48
|
+
[items, search]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const disabledKeys = useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
visibleItems
|
|
54
|
+
.filter(
|
|
55
|
+
(entry): entry is FilterBuilderItem =>
|
|
56
|
+
isFilterItem(entry) && entry.isDisabled === true
|
|
57
|
+
)
|
|
58
|
+
.map((entry) => entry.field),
|
|
59
|
+
[visibleItems]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<MenuTrigger
|
|
64
|
+
onOpenChange={(isOpen) => {
|
|
65
|
+
if (isOpen) setSearch("")
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<Button
|
|
69
|
+
size="sm"
|
|
70
|
+
variant="ghost"
|
|
71
|
+
intent="secondary"
|
|
72
|
+
aria-label="Filter"
|
|
73
|
+
tooltip="Filter"
|
|
74
|
+
>
|
|
75
|
+
<FilterIcon aria-hidden />
|
|
76
|
+
{!activeFilterCount && "Filter"}
|
|
77
|
+
</Button>
|
|
78
|
+
|
|
79
|
+
<MenuPopover placement="bottom start" className="p-0">
|
|
80
|
+
<div className="flex max-h-[400px] min-w-[200px] flex-col">
|
|
81
|
+
<div className="border-gray-6 border-b p-2">
|
|
82
|
+
<SearchInput
|
|
83
|
+
autoFocus
|
|
84
|
+
placeholder="Filter..."
|
|
85
|
+
className="placeholder:text-gray-11 h-7 rounded border-0 bg-transparent px-2 text-xs ring-0!"
|
|
86
|
+
onChange={(value) => setSearch(value)}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{visibleItems.length === 0 ? (
|
|
91
|
+
<div className="text-gray-11 px-3 py-4 text-center text-xs">
|
|
92
|
+
No matching filters
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<Menu className="flex-1 overflow-auto" disabledKeys={disabledKeys}>
|
|
96
|
+
<MenuHeader inset={false} separator={false}>
|
|
97
|
+
<span className="text-gray-11 text-[11px] tracking-wide uppercase">
|
|
98
|
+
Filter by
|
|
99
|
+
</span>
|
|
100
|
+
</MenuHeader>
|
|
101
|
+
|
|
102
|
+
{visibleItems.map((entry, index) => {
|
|
103
|
+
if (!isFilterItem(entry)) {
|
|
104
|
+
return <MenuSeparator key={`separator-${index}`} />
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hasValue = filters[entry.field] != null
|
|
108
|
+
const isDisabled = entry.isDisabled ?? false
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<MenuSubTrigger key={entry.field}>
|
|
112
|
+
<MenuItem id={entry.field} className="data-open:bg-gray-3">
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
{entry.icon && (
|
|
115
|
+
<span
|
|
116
|
+
className={
|
|
117
|
+
isDisabled
|
|
118
|
+
? "text-gray-11 opacity-50"
|
|
119
|
+
: "text-gray-12"
|
|
120
|
+
}
|
|
121
|
+
>
|
|
122
|
+
{entry.icon}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
<span
|
|
126
|
+
className={
|
|
127
|
+
isDisabled
|
|
128
|
+
? "text-gray-11 opacity-50"
|
|
129
|
+
: hasValue
|
|
130
|
+
? "text-gray-12"
|
|
131
|
+
: "text-gray-11"
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{entry.label}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
</MenuItem>
|
|
138
|
+
<MenuPopover className="p-0">
|
|
139
|
+
<FilterSubmenu item={entry} />
|
|
140
|
+
</MenuPopover>
|
|
141
|
+
</MenuSubTrigger>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</Menu>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</MenuPopover>
|
|
148
|
+
</MenuTrigger>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function FilterSubmenu({ item }: { item: FilterBuilderItem }) {
|
|
153
|
+
return (
|
|
154
|
+
<FilterItemProvider item={item} mode="builder">
|
|
155
|
+
{item.render("builder")}
|
|
156
|
+
</FilterItemProvider>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react"
|
|
4
|
+
import { Popover, PopoverDialog } from "@eggspot/ui/components/Popover"
|
|
5
|
+
import { Separator } from "@eggspot/ui/components/Separator"
|
|
6
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
7
|
+
import dayjs from "dayjs"
|
|
8
|
+
import { ChevronDown } from "lucide-react"
|
|
9
|
+
|
|
10
|
+
import { useFilterContext } from "../Filter.store"
|
|
11
|
+
import type { DateFilterMode, DateModeFilterValue } from "../Filter.types"
|
|
12
|
+
|
|
13
|
+
const MODE_LABEL: Record<DateFilterMode, string> = {
|
|
14
|
+
to: "To",
|
|
15
|
+
from: "From",
|
|
16
|
+
range: "Range",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeDateValue(value: unknown): DateModeFilterValue | null {
|
|
20
|
+
if (value == null) return null
|
|
21
|
+
|
|
22
|
+
if (typeof value === "object" && !Array.isArray(value) && "mode" in value) {
|
|
23
|
+
const v = value as DateModeFilterValue
|
|
24
|
+
return {
|
|
25
|
+
...v,
|
|
26
|
+
from: v.from ? new Date(v.from) : undefined,
|
|
27
|
+
to: v.to ? new Date(v.to) : undefined,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
32
|
+
const [fromRaw, toRaw] = value
|
|
33
|
+
const from = fromRaw ? new Date(fromRaw as any) : undefined
|
|
34
|
+
const to = toRaw ? new Date(toRaw as any) : undefined
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
mode: "range",
|
|
38
|
+
from,
|
|
39
|
+
to,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (value instanceof Date || typeof value === "string") {
|
|
44
|
+
const d = new Date(value as any)
|
|
45
|
+
return {
|
|
46
|
+
mode: "to",
|
|
47
|
+
to: d,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function DateModeRowValue({
|
|
55
|
+
value,
|
|
56
|
+
field,
|
|
57
|
+
}: {
|
|
58
|
+
value: unknown
|
|
59
|
+
field: string
|
|
60
|
+
}) {
|
|
61
|
+
const { setFieldValue } = useFilterContext()
|
|
62
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
63
|
+
const triggerRef = useRef<HTMLSpanElement>(null)
|
|
64
|
+
|
|
65
|
+
const v = normalizeDateValue(value)
|
|
66
|
+
const mode: DateFilterMode = v?.mode ?? "to"
|
|
67
|
+
|
|
68
|
+
const applyMode = (next: DateFilterMode) => {
|
|
69
|
+
const base: DateModeFilterValue =
|
|
70
|
+
v ??
|
|
71
|
+
({
|
|
72
|
+
mode: next,
|
|
73
|
+
} as DateModeFilterValue)
|
|
74
|
+
|
|
75
|
+
let nextValue: DateModeFilterValue
|
|
76
|
+
const today = dayjs().endOf("day").toDate()
|
|
77
|
+
|
|
78
|
+
if (next === "from") {
|
|
79
|
+
const existingDate = base.from ?? base.to
|
|
80
|
+
nextValue = {
|
|
81
|
+
mode: "from",
|
|
82
|
+
from: existingDate,
|
|
83
|
+
to: undefined,
|
|
84
|
+
}
|
|
85
|
+
} else if (next === "to") {
|
|
86
|
+
const existingDate = base.to ?? base.from
|
|
87
|
+
nextValue = {
|
|
88
|
+
mode: "to",
|
|
89
|
+
from: undefined,
|
|
90
|
+
to: existingDate,
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
const existingDate = base.from ?? base.to
|
|
94
|
+
if (existingDate) {
|
|
95
|
+
nextValue = {
|
|
96
|
+
mode: "range",
|
|
97
|
+
from: dayjs(existingDate).startOf("day").toDate(),
|
|
98
|
+
to: today,
|
|
99
|
+
preset: base.preset,
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
nextValue = {
|
|
103
|
+
mode: "range",
|
|
104
|
+
from: base.from,
|
|
105
|
+
to: base.to ?? today,
|
|
106
|
+
preset: base.preset,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setFieldValue(field, nextValue)
|
|
112
|
+
setIsOpen(false)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const label = MODE_LABEL[mode]
|
|
116
|
+
const text = formatDateFilterValue(value)
|
|
117
|
+
const hasPreset = !!v?.preset
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
<>
|
|
122
|
+
<span
|
|
123
|
+
ref={triggerRef}
|
|
124
|
+
role="button"
|
|
125
|
+
tabIndex={0}
|
|
126
|
+
onClick={(e) => {
|
|
127
|
+
e.stopPropagation()
|
|
128
|
+
setIsOpen(true)
|
|
129
|
+
}}
|
|
130
|
+
onKeyDown={(e) => {
|
|
131
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
setIsOpen(true)
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
className={cn(
|
|
138
|
+
"inline-flex items-center gap-0.5 rounded-sm py-0.5 pr-1.5 pl-2",
|
|
139
|
+
"text-gray-12 cursor-pointer bg-transparent",
|
|
140
|
+
"hover:bg-gray-5 focus-visible:ring-accent-9 focus-visible:ring-1 focus-visible:outline-none"
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
<span className="capitalize">{label}</span>
|
|
144
|
+
<ChevronDown className="size-3" aria-hidden />
|
|
145
|
+
</span>
|
|
146
|
+
<Separator
|
|
147
|
+
orientation="vertical"
|
|
148
|
+
className="bg-gray-6 mx-2.5 h-4 w-px"
|
|
149
|
+
/>
|
|
150
|
+
</>
|
|
151
|
+
|
|
152
|
+
<span className="text-gray-12">{text}</span>
|
|
153
|
+
|
|
154
|
+
<Popover
|
|
155
|
+
triggerRef={triggerRef}
|
|
156
|
+
isOpen={isOpen}
|
|
157
|
+
onOpenChange={setIsOpen}
|
|
158
|
+
placement="bottom start"
|
|
159
|
+
className="overflow-hidden rounded-lg"
|
|
160
|
+
>
|
|
161
|
+
<PopoverDialog className="bg-gray-2 min-w-[140px] p-1">
|
|
162
|
+
<ModeItem
|
|
163
|
+
active={mode === "from"}
|
|
164
|
+
onClick={(e) => {
|
|
165
|
+
e.stopPropagation()
|
|
166
|
+
applyMode("from")
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<span>From</span>
|
|
170
|
+
</ModeItem>
|
|
171
|
+
<ModeItem
|
|
172
|
+
active={mode === "to"}
|
|
173
|
+
onClick={(e) => {
|
|
174
|
+
e.stopPropagation()
|
|
175
|
+
applyMode("to")
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<span>To</span>
|
|
179
|
+
</ModeItem>
|
|
180
|
+
<ModeItem
|
|
181
|
+
active={mode === "range"}
|
|
182
|
+
onClick={(e) => {
|
|
183
|
+
e.stopPropagation()
|
|
184
|
+
applyMode("range")
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<span>Range</span>
|
|
188
|
+
</ModeItem>
|
|
189
|
+
</PopoverDialog>
|
|
190
|
+
</Popover>
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ModeItem({
|
|
196
|
+
active,
|
|
197
|
+
children,
|
|
198
|
+
onClick,
|
|
199
|
+
}: {
|
|
200
|
+
active: boolean
|
|
201
|
+
children: React.ReactNode
|
|
202
|
+
onClick: React.MouseEventHandler<HTMLButtonElement>
|
|
203
|
+
}) {
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onClick}
|
|
208
|
+
className={cn(
|
|
209
|
+
"text-gray-12 flex w-full items-center rounded-md px-2 py-1 text-xs",
|
|
210
|
+
"hover:bg-gray-3 transition-colors",
|
|
211
|
+
active && "bg-gray-4"
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{children}
|
|
215
|
+
</button>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatDateFilterValue(raw: unknown): string {
|
|
220
|
+
const v = normalizeDateValue(raw)
|
|
221
|
+
if (!v) return "Any time"
|
|
222
|
+
|
|
223
|
+
const fmt = (d?: Date) => (d ? dayjs(d).format("MMM D, YYYY") : "—")
|
|
224
|
+
|
|
225
|
+
const fmtRange = (from?: Date, to?: Date) => {
|
|
226
|
+
if (!from || !to) return `${fmt(from)} - ${fmt(to)}`
|
|
227
|
+
|
|
228
|
+
const fromYear = dayjs(from).year()
|
|
229
|
+
const toYear = dayjs(to).year()
|
|
230
|
+
|
|
231
|
+
if (fromYear === toYear) {
|
|
232
|
+
return `${dayjs(from).format("MMM D")} - ${dayjs(to).format("MMM D, YYYY")}`
|
|
233
|
+
}
|
|
234
|
+
return `${fmt(from)} - ${fmt(to)}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (v.preset && v.from && v.to) {
|
|
238
|
+
return fmtRange(v.from, v.to)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
switch (v.mode) {
|
|
242
|
+
case "from":
|
|
243
|
+
return fmt(v.from)
|
|
244
|
+
case "to":
|
|
245
|
+
return fmt(v.to)
|
|
246
|
+
case "range":
|
|
247
|
+
default:
|
|
248
|
+
return fmtRange(v.from, v.to)
|
|
249
|
+
}
|
|
250
|
+
}
|