@maestro-js/components 1.0.0-alpha.2 → 1.0.0-alpha.21
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/commands/add.ts +16 -1
- package/dist/components.json +1 -1
- package/dist/index.js +14 -1
- package/package.json +5 -5
- package/registry.json +610 -802
- package/scripts/build.ts +11 -4
- package/src/components/alert-dialog.tsx +3 -3
- package/src/components/avatar.tsx +1 -1
- package/src/components/button-link.tsx +3 -0
- package/src/components/button.tsx +4 -0
- package/src/components/checkbox.tsx +7 -7
- package/src/components/chip.tsx +7 -7
- package/src/components/container.tsx +3 -3
- package/src/components/date-range.tsx +138 -0
- package/src/components/dialog.tsx +21 -7
- package/src/components/disclosure.tsx +1 -1
- package/src/components/drawer.tsx +19 -5
- package/src/components/form.tsx +27 -5
- package/src/components/helpers/form-field.tsx +7 -8
- package/src/components/helpers/get-button-classes.ts +23 -23
- package/src/components/helpers/headless-button.tsx +1 -1
- package/src/components/inline-alert.tsx +3 -3
- package/src/components/labeled-value.tsx +2 -2
- package/src/components/menu.tsx +60 -26
- package/src/components/month-day-input.tsx +2 -1
- package/src/components/multiselect.tsx +74 -70
- package/src/components/optional-link.tsx +15 -0
- package/src/components/radio.tsx +8 -8
- package/src/components/select.tsx +9 -8
- package/src/components/stepper.tsx +6 -6
- package/src/components/switch.tsx +7 -7
- package/src/components/table/headless-templated-row-table.ts +550 -0
- package/src/components/table/index.tsx +41 -0
- package/src/components/table/server-table.ts +143 -0
- package/src/components/table/table-actions.tsx +76 -0
- package/src/components/table/table-container.tsx +173 -0
- package/src/components/table/table-container.type-test.ts +155 -0
- package/src/components/table/table-filters/date-range-filter.tsx +227 -0
- package/src/components/table/table-filters/select-filter.tsx +211 -0
- package/src/components/table/table-filters/sort.tsx +85 -0
- package/src/components/table/table-primitives.tsx +226 -0
- package/src/components/table/table-toolbar.tsx +300 -0
- package/src/components/table/table-types.ts +61 -0
- package/src/components/table/use-client-table.ts +157 -0
- package/src/components/table/use-client-table.type-test.ts +217 -0
- package/src/components/table/use-server-table.ts +192 -0
- package/src/components/table/use-server-table.type-test.ts +174 -0
- package/src/components/tabs.tsx +1 -1
- package/src/components/tag-field.tsx +3 -3
- package/src/components/text-area.tsx +1 -1
- package/src/components/text-field.tsx +4 -1
- package/src/components/toast.tsx +6 -6
- package/src/components/toggle-button-group.tsx +2 -2
- package/src/utils/icons.d.ts +2 -1
- package/src/utils/use-query-state.ts +626 -0
- package/src/utils/use-tab-indicator.ts +9 -9
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { NavLink, type NavLinkProps } from 'react-router'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
import { Icon } from '../icon'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { PlainCheckbox } from '../checkbox'
|
|
6
|
+
import type { StandardTable } from './table-types'
|
|
7
|
+
|
|
8
|
+
// --- Layout ---
|
|
9
|
+
|
|
10
|
+
export function TableMain({
|
|
11
|
+
className,
|
|
12
|
+
...props
|
|
13
|
+
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
14
|
+
return <div className={twMerge('max-w-full h-auto', className)} {...props} />
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TableBody({
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="w-full">
|
|
23
|
+
<div
|
|
24
|
+
className={twMerge(
|
|
25
|
+
'flex flex-col flex-nowrap w-full',
|
|
26
|
+
'[&>*+*>*]:border-t [&>*+*>*]:border-gray-950/5' /** select table cells for all but the first row */,
|
|
27
|
+
className
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function TableCell({
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
39
|
+
return <div className={twMerge('basis-0 grow min-w-0 py-3.5 px-2', className)} {...props} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Row ---
|
|
43
|
+
|
|
44
|
+
const TableRowContext = React.createContext<{ table: StandardTable; multiSelect?: boolean }>(null as any)
|
|
45
|
+
export const TableRowContextProvider = TableRowContext.Provider
|
|
46
|
+
|
|
47
|
+
interface TableRowProps {
|
|
48
|
+
row: Record<string, any>
|
|
49
|
+
children?: React.ReactNode
|
|
50
|
+
className?: string
|
|
51
|
+
hideMobileSpacer?: boolean
|
|
52
|
+
['aria-label']?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function TableRow({ row, children, className, hideMobileSpacer, 'aria-label': ariaLabel }: TableRowProps) {
|
|
56
|
+
const { table, multiSelect } = React.useContext(TableRowContext)
|
|
57
|
+
const selected = table.isRowSelected?.(row) ?? false
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={twMerge('flex flex-row w-full h-auto', selected && 'bg-gray-50', className)}
|
|
62
|
+
onClick={(e) => {
|
|
63
|
+
const isTouchableDevice = navigator.maxTouchPoints > 0
|
|
64
|
+
const isSmallScreen = !window.matchMedia('(min-width: 640px)').matches
|
|
65
|
+
|
|
66
|
+
const target = e.target as HTMLElement
|
|
67
|
+
const isInteractiveElement = target.closest('input, label, button, a')
|
|
68
|
+
|
|
69
|
+
if ((isTouchableDevice || isSmallScreen) && !isInteractiveElement) {
|
|
70
|
+
const title = e.currentTarget.querySelector('[data-row-title*="true"]')
|
|
71
|
+
const titleLink = title instanceof HTMLAnchorElement ? title : title?.querySelector('a')
|
|
72
|
+
titleLink?.click()
|
|
73
|
+
}
|
|
74
|
+
}}
|
|
75
|
+
role="row"
|
|
76
|
+
aria-label={ariaLabel}
|
|
77
|
+
>
|
|
78
|
+
{multiSelect ? (
|
|
79
|
+
<div className="relative shrink-0 hidden md:flex items-start pt-3.5 pl-4 pr-2 *:scale-90">
|
|
80
|
+
{selected ? <div className="absolute inset-y-0 left-0 w-0.5 bg-primary-600" /> : null}
|
|
81
|
+
<PlainCheckbox checked={selected} onChange={() => table.toggleRowSelection?.(row)} />
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
{!hideMobileSpacer ? <TableCell className={twMerge('relative grow-0 w-3', multiSelect && 'md:hidden')} /> : null}
|
|
85
|
+
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Title / Description ---
|
|
92
|
+
|
|
93
|
+
export interface TableTitleProps extends React.DetailedHTMLProps<
|
|
94
|
+
React.HTMLAttributes<HTMLParagraphElement>,
|
|
95
|
+
HTMLParagraphElement
|
|
96
|
+
> {}
|
|
97
|
+
|
|
98
|
+
export function TableTitle({ className, ...props }: TableTitleProps) {
|
|
99
|
+
return <p {...props} data-row-title="true" className={twMerge('truncate text-sm font-medium text-blue-600', className)} />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function TableTitleLink({ className, ...props }: NavLinkProps) {
|
|
103
|
+
return (
|
|
104
|
+
<NavLink
|
|
105
|
+
{...props}
|
|
106
|
+
data-row-title="true"
|
|
107
|
+
className={(b) =>
|
|
108
|
+
twMerge('truncate text-sm font-medium text-blue-600', typeof className === 'function' ? className(b) : className)
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface TableDescriptionProps extends React.DetailedHTMLProps<
|
|
115
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
116
|
+
HTMLDivElement
|
|
117
|
+
> {}
|
|
118
|
+
|
|
119
|
+
export function TableDescription({ className, ...props }: TableDescriptionProps) {
|
|
120
|
+
return <div {...props} className={twMerge('truncate text-sm text-gray-500', className)} />
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Footer ---
|
|
124
|
+
|
|
125
|
+
export interface TableFooterProps {
|
|
126
|
+
table: StandardTable
|
|
127
|
+
hideResultCount?: boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function TableFooter({ table, hideResultCount }: TableFooterProps) {
|
|
131
|
+
const { limit, skip } = table.state.pagination
|
|
132
|
+
const rowCount = table.getRowCount()
|
|
133
|
+
|
|
134
|
+
const pageCount = Math.ceil(limit ? rowCount / limit : 1)
|
|
135
|
+
const pageNumber = limit > 0 ? Math.floor(skip / limit) : 0
|
|
136
|
+
|
|
137
|
+
const goToPage = (page: number) => {
|
|
138
|
+
table.setPagination({ limit, skip: page * (limit || 25) })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const canGoPrevious = pageNumber > 0
|
|
142
|
+
const canGoNext = pageNumber < pageCount - 1
|
|
143
|
+
|
|
144
|
+
const showingFrom = skip + 1
|
|
145
|
+
const showingTo = limit ? Math.min(skip + limit, rowCount) : rowCount
|
|
146
|
+
const pages = getPaginationRange(pageCount, pageNumber)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<nav className="flex items-center justify-between border-t border-gray-950/5 px-4 py-2">
|
|
150
|
+
{!hideResultCount ? (
|
|
151
|
+
<p className="text-xs text-gray-400 tabular-nums">
|
|
152
|
+
{showingFrom}–{showingTo} of {rowCount}
|
|
153
|
+
</p>
|
|
154
|
+
) : (
|
|
155
|
+
<div />
|
|
156
|
+
)}
|
|
157
|
+
{pageCount > 1 && (
|
|
158
|
+
<div className="flex items-center gap-0.5">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => canGoPrevious && goToPage(pageNumber - 1)}
|
|
162
|
+
disabled={!canGoPrevious}
|
|
163
|
+
className="inline-flex items-center justify-center rounded-md p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
|
|
164
|
+
aria-label="Previous page"
|
|
165
|
+
>
|
|
166
|
+
<Icon name="chevron-left" className="size-3.5" />
|
|
167
|
+
</button>
|
|
168
|
+
<div className="flex items-center gap-0.5 max-sm:hidden">
|
|
169
|
+
{pages.map((page, i) => {
|
|
170
|
+
if (page === '...') {
|
|
171
|
+
return (
|
|
172
|
+
<span key={`ellipsis-${i}`} className="px-1 text-xs text-gray-400">
|
|
173
|
+
...
|
|
174
|
+
</span>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
const isSelected = page === pageNumber
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
key={page}
|
|
182
|
+
onClick={() => goToPage(page)}
|
|
183
|
+
className={twMerge(
|
|
184
|
+
'inline-flex items-center justify-center size-7 rounded-full text-xs tabular-nums',
|
|
185
|
+
isSelected
|
|
186
|
+
? 'bg-gray-100 text-gray-900 font-medium'
|
|
187
|
+
: 'text-gray-400 hover:bg-gray-50 hover:text-gray-600'
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{page + 1}
|
|
191
|
+
</button>
|
|
192
|
+
)
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
<span className="text-xs tabular-nums text-gray-400 sm:hidden px-1">
|
|
196
|
+
{pageNumber + 1}/{pageCount}
|
|
197
|
+
</span>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => canGoNext && goToPage(pageNumber + 1)}
|
|
201
|
+
disabled={!canGoNext}
|
|
202
|
+
className="inline-flex items-center justify-center rounded-md p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
|
|
203
|
+
aria-label="Next page"
|
|
204
|
+
>
|
|
205
|
+
<Icon name="chevron-right" className="size-3.5" />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</nav>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getPaginationRange(pageCount: number, currentPage: number): (number | '...')[] {
|
|
214
|
+
const delta = 1
|
|
215
|
+
const range: (number | '...')[] = []
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < pageCount; i++) {
|
|
218
|
+
if (i === 0 || i === pageCount - 1 || (i >= currentPage - delta && i <= currentPage + delta)) {
|
|
219
|
+
range.push(i)
|
|
220
|
+
} else if (range[range.length - 1] !== '...') {
|
|
221
|
+
range.push('...')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return range
|
|
226
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge'
|
|
2
|
+
import { type To, Link, useNavigate } from 'react-router'
|
|
3
|
+
import { Icon } from '../icon'
|
|
4
|
+
import { OptionalLink, type OptionalLinkProps } from '../optional-link'
|
|
5
|
+
import { usePress } from '@react-aria/interactions'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
import { mergeProps } from '@react-aria/utils'
|
|
8
|
+
import { HeadlessButton } from '../helpers/headless-button'
|
|
9
|
+
import { PlainCheckbox } from '../checkbox'
|
|
10
|
+
import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'
|
|
11
|
+
import type { StandardTable } from './table-types'
|
|
12
|
+
|
|
13
|
+
// --- Header ---
|
|
14
|
+
|
|
15
|
+
export interface TableHeaderProps {
|
|
16
|
+
table: StandardTable
|
|
17
|
+
multiSelect?: boolean
|
|
18
|
+
multiSelectActions?: React.ReactNode
|
|
19
|
+
children: React.ReactNode
|
|
20
|
+
sticky?: boolean
|
|
21
|
+
hide?: boolean
|
|
22
|
+
action?: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TableHeader({ table, multiSelect, multiSelectActions, children, sticky, hide, action }: TableHeaderProps) {
|
|
26
|
+
const rowCount = table.getRows().length
|
|
27
|
+
const selectionSize = table.state.rowSelection?.size ?? 0
|
|
28
|
+
const hasSelection = multiSelect && selectionSize > 0
|
|
29
|
+
|
|
30
|
+
if (hide) return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={twMerge(
|
|
35
|
+
'bg-gray-100 border-b border-gray-950/10 py-0.5',
|
|
36
|
+
sticky && 'sticky z-20 md:z-[2] top-0',
|
|
37
|
+
hasSelection && 'bg-primary-50 border-primary-200'
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<div className="flex items-center gap-2 pl-4 pr-1.5 min-h-9">
|
|
41
|
+
{action}
|
|
42
|
+
{multiSelect ? (
|
|
43
|
+
<div className="flex items-center *:scale-90">
|
|
44
|
+
<PlainCheckbox
|
|
45
|
+
checked={rowCount > 0 ? selectionSize === rowCount : false}
|
|
46
|
+
onChange={() => table.toggleAllRowSelection?.()}
|
|
47
|
+
isDisabled={rowCount === 0}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
) : null}
|
|
51
|
+
{hasSelection ? (
|
|
52
|
+
<>
|
|
53
|
+
<span className="text-sm text-primary-700 font-medium">{selectionSize} selected</span>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => table.clearRowSelection?.()}
|
|
57
|
+
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
|
58
|
+
>
|
|
59
|
+
<Icon name="x-mark" className="size-4" />
|
|
60
|
+
<span className="max-sm:hidden">Clear</span>
|
|
61
|
+
</button>
|
|
62
|
+
<div className="grow" />
|
|
63
|
+
{multiSelectActions}
|
|
64
|
+
</>
|
|
65
|
+
) : (
|
|
66
|
+
<>
|
|
67
|
+
<div className="grow" />
|
|
68
|
+
<div className="flex overflow-x-scroll scrollbar-hide md:overflow-x-visible h-full gap-2">{children}</div>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Search ---
|
|
77
|
+
|
|
78
|
+
interface TableSearchProps {
|
|
79
|
+
table: StandardTable
|
|
80
|
+
actions?: React.ReactNode
|
|
81
|
+
placeholder?: string | null
|
|
82
|
+
hideActionsOnMobile?: boolean
|
|
83
|
+
hasFilters?: boolean
|
|
84
|
+
hideSearch?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function TableSearch({ table, actions, placeholder, hideActionsOnMobile, hasFilters, hideSearch }: TableSearchProps) {
|
|
88
|
+
const [inputValue, setInputValue] = React.useState(table.state.search ?? '')
|
|
89
|
+
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
setInputValue(table.state.search ?? '')
|
|
92
|
+
}, [table.state.search])
|
|
93
|
+
|
|
94
|
+
const commitSearch = React.useCallback(
|
|
95
|
+
(value: string) => {
|
|
96
|
+
table.setSearch(value)
|
|
97
|
+
},
|
|
98
|
+
[table]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const clearAll = () => {
|
|
102
|
+
table.setSearch('')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="w-full mb-3">
|
|
107
|
+
<div
|
|
108
|
+
className={twMerge(
|
|
109
|
+
'flex',
|
|
110
|
+
hideActionsOnMobile ? 'md:flex-row-reverse md:gap-x-2 flex-wrap' : 'flex-row-reverse gap-x-2'
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<div className={twMerge(hideActionsOnMobile && 'basis-full md:basis-auto')}>{actions}</div>
|
|
114
|
+
{hideSearch ? null : (
|
|
115
|
+
<form
|
|
116
|
+
className="flex grow"
|
|
117
|
+
onSubmit={(e) => {
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
commitSearch(inputValue)
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<div className="grow relative max-w-2xl" role="search">
|
|
123
|
+
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
124
|
+
<Icon name="magnifying-glass" className="size-4 text-gray-400" aria-hidden="true" />
|
|
125
|
+
</div>
|
|
126
|
+
<input
|
|
127
|
+
className={twMerge(
|
|
128
|
+
'w-full max-w-2xl pl-9 rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-black/10 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 text-sm/6 max-sm:text-base/6',
|
|
129
|
+
hasFilters ? 'pr-8' : 'pr-3'
|
|
130
|
+
)}
|
|
131
|
+
enterKeyHint="search"
|
|
132
|
+
placeholder={placeholder || undefined}
|
|
133
|
+
value={inputValue}
|
|
134
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
135
|
+
/>
|
|
136
|
+
{table.state.search ? (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={clearAll}
|
|
140
|
+
className="absolute inset-y-0 right-0 flex items-center pr-2.5"
|
|
141
|
+
aria-label="Clear search"
|
|
142
|
+
>
|
|
143
|
+
<Icon name="x-mark" className="size-4 text-gray-400 hover:text-gray-600" />
|
|
144
|
+
</button>
|
|
145
|
+
) : null}
|
|
146
|
+
</div>
|
|
147
|
+
</form>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Actions ---
|
|
155
|
+
|
|
156
|
+
export type NavigationAction = { label: string; to: To; replace?: boolean; preventScrollReset?: boolean; disabled?: boolean }
|
|
157
|
+
export type ButtonAction = { label: string; onClick: () => void; disabled?: boolean }
|
|
158
|
+
export type ActionParams = NavigationAction | ButtonAction
|
|
159
|
+
|
|
160
|
+
export type TableActionProps = {
|
|
161
|
+
actions: (ActionParams | null | undefined)[] | null | undefined
|
|
162
|
+
maxVisibleActions?: number
|
|
163
|
+
compact?: boolean
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isNavigationAction(action: ActionParams): action is NavigationAction {
|
|
167
|
+
return 'to' in action
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function TableActions({ actions: rawActions, maxVisibleActions = 4, compact }: TableActionProps) {
|
|
171
|
+
const navigate = useNavigate()
|
|
172
|
+
const actions = (rawActions?.filter(Boolean) || []) as ActionParams[]
|
|
173
|
+
|
|
174
|
+
// If we have more actions than the max, always use dropdown on desktop too
|
|
175
|
+
const useDropdownOnDesktop = actions.length > maxVisibleActions
|
|
176
|
+
|
|
177
|
+
const dropdownTriggerClassName = compact
|
|
178
|
+
? 'inline-flex items-center rounded px-2 py-0.5 text-sm font-medium text-primary-700 hover:text-primary-900 hover:bg-primary-100 truncate'
|
|
179
|
+
: twMerge(
|
|
180
|
+
'inline-flex items-center rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900',
|
|
181
|
+
'shadow-sm ring-1 ring-black/10',
|
|
182
|
+
'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-white',
|
|
183
|
+
'hover:bg-gray-50 truncate'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
<div className={useDropdownOnDesktop ? '' : 'xl:hidden'}>
|
|
189
|
+
{actions.length ? (
|
|
190
|
+
<MenuTrigger>
|
|
191
|
+
<Button className={dropdownTriggerClassName}>
|
|
192
|
+
Actions
|
|
193
|
+
<Icon name="chevron-down" className="h-5 w-5" />
|
|
194
|
+
</Button>
|
|
195
|
+
<Popover offset={4} className="w-48 rounded-md shadow-lg ring-1 ring-black/10 bg-white z-20">
|
|
196
|
+
<Menu className="outline-none py-1">
|
|
197
|
+
{actions.map((a) => (
|
|
198
|
+
<MenuItem
|
|
199
|
+
key={a.label}
|
|
200
|
+
isDisabled={a.disabled}
|
|
201
|
+
onAction={() => {
|
|
202
|
+
if (isNavigationAction(a)) {
|
|
203
|
+
navigate(a.to, { replace: a.replace, preventScrollReset: a.preventScrollReset })
|
|
204
|
+
} else {
|
|
205
|
+
a.onClick()
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
className={({ isFocused }) =>
|
|
209
|
+
twMerge(
|
|
210
|
+
'text-sm px-4 py-2 cursor-pointer outline-none',
|
|
211
|
+
isFocused ? 'text-white bg-primary-500' : 'text-gray-900',
|
|
212
|
+
a.disabled && 'opacity-50 cursor-not-allowed'
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{a.label}
|
|
217
|
+
</MenuItem>
|
|
218
|
+
))}
|
|
219
|
+
</Menu>
|
|
220
|
+
</Popover>
|
|
221
|
+
</MenuTrigger>
|
|
222
|
+
) : null}
|
|
223
|
+
</div>
|
|
224
|
+
{!useDropdownOnDesktop ? (
|
|
225
|
+
<div className="gap-x-2 hidden xl:flex">
|
|
226
|
+
{actions.map((a) =>
|
|
227
|
+
isNavigationAction(a) ? (
|
|
228
|
+
<ActionLink
|
|
229
|
+
to={a.to}
|
|
230
|
+
replace={a.replace}
|
|
231
|
+
key={a.label}
|
|
232
|
+
preventScrollReset={a.preventScrollReset}
|
|
233
|
+
disabled={a.disabled}
|
|
234
|
+
compact={compact}
|
|
235
|
+
>
|
|
236
|
+
{a.label}
|
|
237
|
+
</ActionLink>
|
|
238
|
+
) : (
|
|
239
|
+
<ActionButton key={a.label} onClick={a.onClick} disabled={a.disabled} compact={compact}>
|
|
240
|
+
{a.label}
|
|
241
|
+
</ActionButton>
|
|
242
|
+
)
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
) : null}
|
|
246
|
+
</>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function ActionLink(props: OptionalLinkProps & { compact?: boolean }) {
|
|
251
|
+
const ref = React.useRef<HTMLAnchorElement>(null)
|
|
252
|
+
const { isPressed, pressProps } = usePress({
|
|
253
|
+
ref
|
|
254
|
+
})
|
|
255
|
+
const { compact, ...linkProps } = props
|
|
256
|
+
return (
|
|
257
|
+
<OptionalLink
|
|
258
|
+
{...mergeProps(pressProps, linkProps)}
|
|
259
|
+
ref={ref}
|
|
260
|
+
className={twMerge(
|
|
261
|
+
props.className,
|
|
262
|
+
props.disabled && 'cursor-not-allowed opacity-30',
|
|
263
|
+
compact
|
|
264
|
+
? 'inline-flex items-center rounded px-2 py-0.5 text-sm font-medium text-primary-700 hover:text-primary-900 hover:bg-primary-100 truncate'
|
|
265
|
+
: twMerge(
|
|
266
|
+
'inline-flex items-center rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900',
|
|
267
|
+
'shadow-sm ring-1 ring-black/10',
|
|
268
|
+
'hover:bg-gray-50 truncate'
|
|
269
|
+
),
|
|
270
|
+
isPressed && 'scale-[.98]'
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
{props.children}
|
|
274
|
+
</OptionalLink>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type ActionButtonProps = React.ComponentProps<typeof HeadlessButton> & { compact?: boolean }
|
|
279
|
+
|
|
280
|
+
function ActionButton(props: ActionButtonProps) {
|
|
281
|
+
const { className, compact, ...restProps } = props
|
|
282
|
+
return (
|
|
283
|
+
<HeadlessButton
|
|
284
|
+
{...restProps}
|
|
285
|
+
className={twMerge(
|
|
286
|
+
typeof className === 'function' ? className({ isPressed: false }) : className,
|
|
287
|
+
props.disabled && 'cursor-not-allowed opacity-30',
|
|
288
|
+
compact
|
|
289
|
+
? 'inline-flex items-center rounded px-2 py-0.5 text-sm font-medium text-primary-700 hover:text-primary-900 hover:bg-primary-100 truncate'
|
|
290
|
+
: twMerge(
|
|
291
|
+
'inline-flex items-center rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900',
|
|
292
|
+
'shadow-sm ring-1 ring-black/10',
|
|
293
|
+
'hover:bg-gray-50 truncate data-[pressed=true]:scale-[.98]'
|
|
294
|
+
)
|
|
295
|
+
)}
|
|
296
|
+
>
|
|
297
|
+
{props.children}
|
|
298
|
+
</HeadlessButton>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { Iso } from 'iso-fns'
|
|
3
|
+
import type { DateRangeRootAdditionalProps } from '../date-range'
|
|
4
|
+
|
|
5
|
+
type PaginationState = { limit: number; skip: number }
|
|
6
|
+
type SetterOrUpdater<T> = T | ((prev: T) => T)
|
|
7
|
+
|
|
8
|
+
export type StandardTable<
|
|
9
|
+
FC extends Record<string, any> = Record<string, any>,
|
|
10
|
+
SC extends Record<string, any> = Record<string, any>
|
|
11
|
+
> = {
|
|
12
|
+
getRows(): Record<string, any>[]
|
|
13
|
+
getRowCount(): number
|
|
14
|
+
getFilterConfig(): FC
|
|
15
|
+
getSortConfig(): { [K in keyof SC]: null }
|
|
16
|
+
state: {
|
|
17
|
+
filters: { [K in keyof FC]: FC[K] | null | undefined }
|
|
18
|
+
sort: (keyof SC & string) | null
|
|
19
|
+
search: string
|
|
20
|
+
pagination: PaginationState
|
|
21
|
+
rowSelection?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
setFilters(val: SetterOrUpdater<{ [K in keyof FC]: FC[K] | null | undefined }>): void
|
|
24
|
+
setSort(val: any): void
|
|
25
|
+
setSearch(val: SetterOrUpdater<string>): void
|
|
26
|
+
setPagination(val: SetterOrUpdater<PaginationState>): void
|
|
27
|
+
toggleAllRowSelection?(): void
|
|
28
|
+
toggleRowSelection?(row: Record<string, any>): void
|
|
29
|
+
isRowSelected?(row: Record<string, any>): boolean
|
|
30
|
+
clearRowSelection?(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type DefaultTableFilter<V = any> = {
|
|
34
|
+
label: string
|
|
35
|
+
type?: never
|
|
36
|
+
options: { label: string; value: V; className?: string; icon?: ReactNode }[]
|
|
37
|
+
autoComplete?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type DateRangeTableFilter = {
|
|
41
|
+
label: string
|
|
42
|
+
type: 'date-range'
|
|
43
|
+
options?: Pick<DateRangeRootAdditionalProps, 'shouldDisableDate' | 'shouldDisableEndDate' | 'shouldDisableStartDate'> & {
|
|
44
|
+
quickPresets?: { label: string; value: { startDate: Iso.Date; endDate: Iso.Date } }[]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TableFilterLocalization<V = any> = 0 extends 1 & V
|
|
49
|
+
? DefaultTableFilter | DateRangeTableFilter
|
|
50
|
+
: [V] extends [{ startDate: Iso.Date; endDate: Iso.Date }]
|
|
51
|
+
? DateRangeTableFilter
|
|
52
|
+
: DefaultTableFilter<V>
|
|
53
|
+
|
|
54
|
+
export type TableLocalization<
|
|
55
|
+
FC extends Record<string, any> = Record<string, any>,
|
|
56
|
+
SC extends Record<string, any> = Record<string, any>
|
|
57
|
+
> = {
|
|
58
|
+
filters: { [K in keyof FC]: TableFilterLocalization<FC[K]> }
|
|
59
|
+
sorts: { [key in keyof SC]: { label: string } }
|
|
60
|
+
searchPlaceholder?: string
|
|
61
|
+
}
|