@maestro-js/components 1.0.0-alpha.0 → 1.0.0-alpha.10

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.
Files changed (55) hide show
  1. package/commands/add.ts +16 -1
  2. package/dist/components.json +1 -1
  3. package/dist/index.js +14 -1
  4. package/package.json +5 -5
  5. package/registry.json +610 -802
  6. package/scripts/build.ts +11 -4
  7. package/src/components/alert-dialog.tsx +3 -3
  8. package/src/components/avatar.tsx +1 -1
  9. package/src/components/button-link.tsx +3 -0
  10. package/src/components/button.tsx +4 -0
  11. package/src/components/checkbox.tsx +7 -7
  12. package/src/components/chip.tsx +7 -7
  13. package/src/components/container.tsx +3 -3
  14. package/src/components/date-range.tsx +161 -0
  15. package/src/components/dialog.tsx +21 -7
  16. package/src/components/disclosure.tsx +1 -1
  17. package/src/components/drawer.tsx +19 -5
  18. package/src/components/form.tsx +27 -5
  19. package/src/components/helpers/form-field.tsx +7 -8
  20. package/src/components/helpers/get-button-classes.ts +23 -23
  21. package/src/components/helpers/headless-button.tsx +1 -1
  22. package/src/components/inline-alert.tsx +3 -3
  23. package/src/components/labeled-value.tsx +2 -2
  24. package/src/components/menu.tsx +60 -26
  25. package/src/components/month-day-input.tsx +2 -1
  26. package/src/components/multiselect.tsx +74 -70
  27. package/src/components/optional-link.tsx +15 -0
  28. package/src/components/radio.tsx +8 -8
  29. package/src/components/select.tsx +9 -8
  30. package/src/components/stepper.tsx +6 -6
  31. package/src/components/switch.tsx +7 -7
  32. package/src/components/table/headless-templated-row-table.ts +550 -0
  33. package/src/components/table/index.tsx +41 -0
  34. package/src/components/table/server-table.ts +143 -0
  35. package/src/components/table/table-actions.tsx +76 -0
  36. package/src/components/table/table-container.tsx +173 -0
  37. package/src/components/table/table-container.type-test.ts +155 -0
  38. package/src/components/table/table-filters/date-range-filter.tsx +227 -0
  39. package/src/components/table/table-filters/select-filter.tsx +211 -0
  40. package/src/components/table/table-filters/sort.tsx +85 -0
  41. package/src/components/table/table-primitives.tsx +226 -0
  42. package/src/components/table/table-toolbar.tsx +300 -0
  43. package/src/components/table/table-types.ts +61 -0
  44. package/src/components/table/use-client-table.ts +157 -0
  45. package/src/components/table/use-client-table.type-test.ts +217 -0
  46. package/src/components/table/use-server-table.ts +192 -0
  47. package/src/components/table/use-server-table.type-test.ts +174 -0
  48. package/src/components/tabs.tsx +1 -1
  49. package/src/components/tag-field.tsx +3 -3
  50. package/src/components/text-area.tsx +1 -1
  51. package/src/components/toast.tsx +6 -6
  52. package/src/components/toggle-button-group.tsx +2 -2
  53. package/src/utils/icons.d.ts +2 -1
  54. package/src/utils/use-query-state.ts +626 -0
  55. package/src/utils/use-tab-indicator.ts +9 -9
@@ -0,0 +1,227 @@
1
+ import { twMerge } from 'tailwind-merge'
2
+ import React from 'react'
3
+ import { dateFns, type Iso } from 'iso-fns'
4
+ import { Button } from '../../button'
5
+ import type { StandardTable } from '../table-types'
6
+ import { Button as AriaButton, Dialog, DialogTrigger, Popover } from 'react-aria-components'
7
+ import { Icon } from '../../icon'
8
+ import { DateRangePicker, type DateRangeRootAdditionalProps } from '../../date-range'
9
+
10
+ export function TableDateRangeFilter({
11
+ table,
12
+ label,
13
+ value,
14
+ name,
15
+ options
16
+ }: {
17
+ table: StandardTable
18
+ label: string
19
+ name: string
20
+ value: null | { startDate: Iso.Date; endDate: Iso.Date }
21
+ options?: Partial<
22
+ Pick<DateRangeRootAdditionalProps, 'shouldDisableDate' | 'shouldDisableEndDate' | 'shouldDisableStartDate'>
23
+ > & {
24
+ quickPresets?: { label: string; value: { startDate: Iso.Date; endDate: Iso.Date } }[]
25
+ }
26
+ }) {
27
+ const [startDate, endDate] = value ? [value.startDate, value.endDate] : [null, null]
28
+
29
+ return (
30
+ <DialogTrigger>
31
+ <AriaButton
32
+ className={twMerge(
33
+ 'text-sm px-2 h-full flex text-gray-500 focus:outline-none whitespace-pre',
34
+ 'rounded-full border data-[pressed]:scale-[.98]',
35
+ 'md:my-3',
36
+ value ? 'text-primary-400 font-semibold' : ''
37
+ )}
38
+ >
39
+ {label} {startDate && endDate ? getTableDateRangeFilterValue({ startDate, endDate }, options?.quickPresets) : ''}
40
+ <Icon name="chevron-down" className="ml-1 h-5 w-5" />
41
+ </AriaButton>
42
+ <Popover offset={4} className="z-20 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 bg-white">
43
+ <Dialog className="outline-none">
44
+ {({ close }) => (
45
+ <TableDateRangeFilterInner
46
+ table={table}
47
+ filterName={name}
48
+ closePopover={close}
49
+ defaultStart={startDate}
50
+ defaultEnd={endDate}
51
+ shouldDisableDate={options?.shouldDisableDate ?? (() => false)}
52
+ shouldDisableEndDate={options?.shouldDisableEndDate ?? (() => false)}
53
+ shouldDisableStartDate={options?.shouldDisableStartDate ?? (() => false)}
54
+ quickPresets={options?.quickPresets}
55
+ />
56
+ )}
57
+ </Dialog>
58
+ </Popover>
59
+ </DialogTrigger>
60
+ )
61
+ }
62
+
63
+ function TableDateRangeFilterInner({
64
+ table,
65
+ filterName,
66
+ closePopover,
67
+ defaultStart,
68
+ defaultEnd,
69
+ shouldDisableDate,
70
+ shouldDisableEndDate,
71
+ shouldDisableStartDate,
72
+ quickPresets
73
+ }: {
74
+ table: StandardTable
75
+ filterName: string
76
+ closePopover(): void
77
+ quickPresets?: { label: string; value: { startDate: Iso.Date; endDate: Iso.Date } }[]
78
+ } & DateRangeRootAdditionalProps) {
79
+ const [start, setStart] = React.useState<DateRangeRootAdditionalProps['defaultStart']>(defaultStart)
80
+ const [end, setEnd] = React.useState<DateRangeRootAdditionalProps['defaultEnd']>(defaultEnd)
81
+ const [currentField, setCurrentField] = React.useState<'start' | 'end' | null>(null)
82
+
83
+ const isPresetAvailable = !!quickPresets?.length
84
+
85
+ const applyFilter = (dateRange: { startDate: Iso.Date; endDate: Iso.Date }) => {
86
+ table.setFilters((prev: Record<string, any>) => ({ ...prev, [filterName]: dateRange }))
87
+ }
88
+
89
+ const onQuickPresetClick = (presetValue: { startDate: Iso.Date; endDate: Iso.Date }) => {
90
+ setCurrentField(null)
91
+ closePopover()
92
+ applyFilter(presetValue)
93
+ }
94
+
95
+ // Note: DateRangePicker and its useDateRangePicker hook need to be adapted
96
+ // from headless-primitives. For now we pass through the same shape.
97
+ const pickerControls = {
98
+ start: start ?? null,
99
+ end: end ?? null,
100
+ onStartChange(v: Iso.Date | null, f: 'start' | 'end' | null) {
101
+ setStart(v)
102
+ setCurrentField(f)
103
+ },
104
+ onEndChange(v: Iso.Date | null, f: 'start' | 'end' | null) {
105
+ setEnd(v)
106
+ setCurrentField(f)
107
+ },
108
+ currentField,
109
+ shouldDisableDate,
110
+ shouldDisableEndDate,
111
+ shouldDisableStartDate
112
+ }
113
+
114
+ return (
115
+ <div className="flex bg-white overflow-auto rounded-md text-base sm:text-sm">
116
+ {isPresetAvailable ? (
117
+ <QuickPresetPanel
118
+ start={start}
119
+ end={end}
120
+ containerClassName="border-solid border-r border-gray-200 py-2 overflow-y-auto sm:min-w-[200px] hidden sm:block"
121
+ className={twMerge('px-4 py-2 w-full text-left')}
122
+ quickPresets={quickPresets}
123
+ onQuickPresetClick={onQuickPresetClick}
124
+ />
125
+ ) : null}
126
+ <div>
127
+ <div className="px-3 py-3">
128
+ <DateRangePicker {...pickerControls} />
129
+ </div>
130
+ {isPresetAvailable ? (
131
+ <QuickPresetPanel
132
+ start={start}
133
+ end={end}
134
+ containerClassName="border-solid border-y border-gray-200 p-2 flex gap-1 flex-wrap sm:hidden"
135
+ className={twMerge('px-3 py-1 rounded-full border')}
136
+ quickPresets={quickPresets}
137
+ onQuickPresetClick={onQuickPresetClick}
138
+ />
139
+ ) : null}
140
+ <div className="p-2 float-right flex gap-2">
141
+ <Button variant="soft" onClick={closePopover}>
142
+ Cancel
143
+ </Button>
144
+ <Button
145
+ onClick={() => {
146
+ closePopover()
147
+ if (start && end && start <= end) {
148
+ applyFilter({ startDate: start as Iso.Date, endDate: end as Iso.Date })
149
+ }
150
+ }}
151
+ >
152
+ Apply
153
+ </Button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function QuickPresetPanel({
161
+ start,
162
+ end,
163
+ quickPresets,
164
+ onQuickPresetClick,
165
+ className,
166
+ containerClassName
167
+ }: {
168
+ start?: string | null
169
+ end?: string | null
170
+ quickPresets: { label: string; value: { startDate: Iso.Date; endDate: Iso.Date } }[]
171
+ onQuickPresetClick: (value: { startDate: Iso.Date; endDate: Iso.Date }) => void
172
+ className?: string
173
+ containerClassName?: string
174
+ }) {
175
+ return (
176
+ <div className={containerClassName}>
177
+ {quickPresets.map(({ label, value }) => {
178
+ const [presetStartDate, presetEndDate] = [value.startDate, value.endDate]
179
+ return (
180
+ <button
181
+ key={label}
182
+ className={twMerge(
183
+ 'text-sm cursor-pointer',
184
+ className,
185
+ 'text-gray-900 bg-white text-nowrap',
186
+ 'hover:text-white hover:bg-primary-500',
187
+ 'focus:text-white focus:bg-primary-500',
188
+ presetStartDate === start && presetEndDate === end && 'bg-primary-500 text-white'
189
+ )}
190
+ onClick={() => onQuickPresetClick(value)}
191
+ >
192
+ {label}
193
+ </button>
194
+ )
195
+ })}
196
+ </div>
197
+ )
198
+ }
199
+
200
+ export function getTableDateRangeFilterValue(
201
+ value: { startDate: Iso.Date; endDate: Iso.Date },
202
+ quickPreset?: { label: string; value: { startDate: Iso.Date; endDate: Iso.Date } }[]
203
+ ) {
204
+ if (quickPreset) {
205
+ const preset = quickPreset.find((p) => p.value.startDate === value.startDate && p.value.endDate === value.endDate)
206
+ if (preset) {
207
+ return preset.label
208
+ }
209
+ }
210
+ if (dateFns.getYear(value.startDate) !== dateFns.getYear(value.endDate)) {
211
+ return getFormattedDateRange(value, 'MMM dd yyyy')
212
+ }
213
+ if (
214
+ dateFns.getYear(value.startDate) === dateFns.getYear(dateFns.now()) &&
215
+ dateFns.getYear(value.endDate) === dateFns.getYear(dateFns.now())
216
+ ) {
217
+ return getFormattedDateRange(value, 'MMM dd')
218
+ }
219
+ return getFormattedDateRange(value, 'MMM dd yyyy')
220
+ }
221
+
222
+ function getFormattedDateRange(value: { startDate: Iso.Date; endDate: Iso.Date }, format: string) {
223
+ if (value.startDate === value.endDate) {
224
+ return dateFns.format(value.startDate, format)
225
+ }
226
+ return `${dateFns.format(value.startDate, format)} - ${dateFns.format(value.endDate, format)}`
227
+ }
@@ -0,0 +1,211 @@
1
+ import { twMerge } from 'tailwind-merge'
2
+ import { Icon } from '../../icon'
3
+ import type { ReactNode } from 'react'
4
+ import type { StandardTable } from '../table-types'
5
+ import { Button, ComboBox, Input, ListBox, ListBoxItem, Popover, Select } from 'react-aria-components'
6
+ import React from 'react'
7
+
8
+ interface TableSelectFilterProps {
9
+ table: StandardTable
10
+ label: string
11
+ name: string
12
+ options: { label: string; value: any; className?: string; icon?: ReactNode }[]
13
+ value: any
14
+ autoComplete?: boolean
15
+ }
16
+
17
+ export function TableSelectFilter({ table, label, name, options, value, autoComplete }: TableSelectFilterProps) {
18
+ const setFilter = (newValue: any) => {
19
+ const toggledValue = newValue === value ? null : newValue
20
+ table.setFilters((prev: Record<string, any>) => ({ ...prev, [name]: toggledValue }))
21
+ }
22
+
23
+ if (autoComplete) {
24
+ return <AutoCompleteFilter label={label} options={options} value={value} onSelect={setFilter} />
25
+ }
26
+
27
+ return <DropdownFilter label={label} options={options} value={value} onSelect={setFilter} />
28
+ }
29
+
30
+ function DropdownFilter({
31
+ label,
32
+ options,
33
+ value,
34
+ onSelect
35
+ }: {
36
+ label: string
37
+ options: TableSelectFilterProps['options']
38
+ value: any
39
+ onSelect: (value: any) => void
40
+ }) {
41
+ return (
42
+ <Select
43
+ aria-label={label}
44
+ selectedKey={value != null ? String(value) : null}
45
+ onSelectionChange={(key) => {
46
+ if (key === '__clear__') return onSelect(null)
47
+ const option = options.find((o) => String(o.value) === String(key))
48
+ onSelect(option?.value ?? null)
49
+ }}
50
+ >
51
+ <Button
52
+ className={twMerge(
53
+ 'text-sm px-2 h-full flex text-gray-500 focus:outline-none whitespace-pre',
54
+ 'rounded-full ring-1 ring-black/10 data-[pressed]:scale-[.98]',
55
+ 'my-1',
56
+ value != null ? 'text-blue-600 font-medium' : ''
57
+ )}
58
+ >
59
+ {label}
60
+ <Icon name="chevron-down" className="ml-1 h-5 w-5" />
61
+ </Button>
62
+ <Popover offset={4} className="w-56 rounded-md shadow-lg ring-1 ring-black/10 bg-white z-20">
63
+ <ListBox className="outline-none max-h-60 overflow-y-auto py-1">
64
+ {value != null && (
65
+ <ListBoxItem
66
+ id="__clear__"
67
+ textValue="Clear"
68
+ className={({ isFocused }) =>
69
+ twMerge(
70
+ 'text-sm px-4 py-2 cursor-pointer outline-none border-b border-gray-100',
71
+ isFocused ? 'text-white bg-primary-500' : 'text-gray-500'
72
+ )
73
+ }
74
+ >
75
+ Clear filter
76
+ </ListBoxItem>
77
+ )}
78
+ {options.map((o) => (
79
+ <FilterOption key={o.label} option={o} selected={value === o.value} />
80
+ ))}
81
+ </ListBox>
82
+ </Popover>
83
+ </Select>
84
+ )
85
+ }
86
+
87
+ export function FilterOption({
88
+ option,
89
+ selected
90
+ }: {
91
+ option: { label: string; value: any; className?: string; icon?: ReactNode }
92
+ selected: boolean
93
+ }) {
94
+ return (
95
+ <ListBoxItem
96
+ id={String(option.value)}
97
+ textValue={option.label}
98
+ className={({ isFocused }) =>
99
+ twMerge(
100
+ 'group items-center text-sm px-4 py-2 relative flex justify-between w-full cursor-pointer outline-none',
101
+ isFocused ? 'text-white bg-primary-500' : 'text-gray-900',
102
+ option.className
103
+ )
104
+ }
105
+ >
106
+ {({ isFocused }) => (
107
+ <>
108
+ <div
109
+ className={twMerge(
110
+ selected ? 'font-semibold' : 'font-normal',
111
+ option.icon ? 'flex gap-1 items-center' : 'block'
112
+ )}
113
+ >
114
+ {option.icon}
115
+ {option.label}
116
+ </div>
117
+ {selected ? (
118
+ <div className={twMerge(isFocused ? 'text-white' : 'text-primary-500', 'flex items-center')}>
119
+ <Icon name="check" className="h-5 w-5" aria-hidden="true" />
120
+ </div>
121
+ ) : null}
122
+ </>
123
+ )}
124
+ </ListBoxItem>
125
+ )
126
+ }
127
+
128
+ interface AutoCompleteFilterProps {
129
+ label: string
130
+ options: { label: string; value: any; className?: string; icon?: ReactNode }[]
131
+ value: any
132
+ onSelect: (value: any) => void
133
+ }
134
+
135
+ function AutoCompleteFilter({ label, options, value, onSelect }: AutoCompleteFilterProps) {
136
+ const [query, setQuery] = React.useState('')
137
+
138
+ const filteredOptions =
139
+ query === ''
140
+ ? options
141
+ : options.filter((o) => {
142
+ const q = query.toLowerCase()
143
+ return o.label.toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q)
144
+ })
145
+
146
+ return (
147
+ <ComboBox
148
+ aria-label={label}
149
+ inputValue={query}
150
+ onInputChange={setQuery}
151
+ selectedKey={value != null ? String(value) : null}
152
+ onSelectionChange={(key) => {
153
+ if (key === null) return
154
+ if (key === '__clear__') {
155
+ onSelect(null)
156
+ setQuery('')
157
+ return
158
+ }
159
+ const option = options.find((o) => String(o.value) === String(key))
160
+ onSelect(option?.value ?? null)
161
+ setQuery('')
162
+ }}
163
+ menuTrigger="focus"
164
+ >
165
+ <div className="relative inline-block text-left">
166
+ <Button
167
+ className={twMerge(
168
+ 'text-sm px-2 h-full flex text-gray-500 focus:outline-none whitespace-pre',
169
+ 'rounded-full ring-1 ring-black/10 data-[pressed]:scale-[.98]',
170
+ 'my-1',
171
+ value != null ? 'text-blue-600 font-medium' : ''
172
+ )}
173
+ >
174
+ {label}
175
+ <Icon name="chevron-down" className="ml-1 h-5 w-5" />
176
+ </Button>
177
+ </div>
178
+ <Popover offset={4} className="w-56 rounded-md shadow-lg ring-1 ring-black/10 bg-white z-20">
179
+ <div className="flex p-2">
180
+ <Input
181
+ className={twMerge(
182
+ 'text-sm block rounded-md focus:ring-0 border-0 focus:outline-none px-0 py-0 w-0 grow min-w-[30px]',
183
+ 'py-1 px-1'
184
+ )}
185
+ placeholder="Filter..."
186
+ autoFocus
187
+ />
188
+ </div>
189
+ <ListBox className="outline-none max-h-60 overflow-y-auto py-1">
190
+ {value != null && (
191
+ <ListBoxItem
192
+ id="__clear__"
193
+ textValue="Clear"
194
+ className={({ isFocused }) =>
195
+ twMerge(
196
+ 'text-sm px-4 py-2 cursor-pointer outline-none border-b border-gray-100',
197
+ isFocused ? 'text-white bg-primary-500' : 'text-gray-500'
198
+ )
199
+ }
200
+ >
201
+ Clear filter
202
+ </ListBoxItem>
203
+ )}
204
+ {filteredOptions.map((o) => (
205
+ <FilterOption key={o.label} option={o} selected={value === o.value} />
206
+ ))}
207
+ </ListBox>
208
+ </Popover>
209
+ </ComboBox>
210
+ )
211
+ }
@@ -0,0 +1,85 @@
1
+ import { twMerge } from 'tailwind-merge'
2
+ import { Icon } from '../../icon'
3
+ import React from 'react'
4
+ import type { StandardTable } from '../table-types'
5
+ import { Button, ListBox, ListBoxItem, Popover, Select } from 'react-aria-components'
6
+
7
+ export interface TableSelectSortProps {
8
+ table: StandardTable
9
+ value: string | null
10
+ options: { value: string; label: string }[]
11
+ search: string | null
12
+ }
13
+
14
+ export function TableSelectSort({ table, value, options, search }: TableSelectSortProps) {
15
+ const optionsWithRelevant = React.useMemo(() => {
16
+ return search ? [{ label: 'Most Relevant', value: 'mostRelevant' }, ...options] : options
17
+ }, [options, search])
18
+
19
+ return (
20
+ <Select
21
+ aria-label="Sort"
22
+ selectedKey={value ?? null}
23
+ onSelectionChange={(key) => {
24
+ table.setSort(key === '__clear__' ? null : key ? String(key) : null)
25
+ }}
26
+ >
27
+ <Button
28
+ className={twMerge(
29
+ 'text-sm px-2 h-full flex text-gray-500',
30
+ 'rounded-full ring-1 ring-black/10 data-[pressed]:scale-[.98]',
31
+ 'my-1'
32
+ )}
33
+ >
34
+ Sort
35
+ <Icon name="chevron-down" className="ml-1 h-5 w-5" />
36
+ </Button>
37
+ <Popover offset={4} className="w-56 rounded-md shadow-lg ring-1 ring-black/10 bg-white z-20">
38
+ <ListBox className="outline-none max-h-60 overflow-y-auto py-1">
39
+ {value != null && (
40
+ <ListBoxItem
41
+ key="__clear__"
42
+ id="__clear__"
43
+ textValue="Clear"
44
+ className={({ isFocused }) =>
45
+ twMerge(
46
+ 'text-sm px-4 py-2 cursor-pointer outline-none border-b border-gray-100',
47
+ isFocused ? 'text-white bg-primary-500' : 'text-gray-500'
48
+ )
49
+ }
50
+ >
51
+ Clear sort
52
+ </ListBoxItem>
53
+ )}
54
+ {optionsWithRelevant.map((o) => {
55
+ const selected = value === o.value
56
+ return (
57
+ <ListBoxItem
58
+ key={o.value}
59
+ id={o.value}
60
+ textValue={o.label}
61
+ className={({ isFocused }) =>
62
+ twMerge(
63
+ 'group items-center text-sm px-4 py-2 relative flex justify-between w-full cursor-pointer outline-none',
64
+ isFocused ? 'text-white bg-primary-500' : 'text-gray-900'
65
+ )
66
+ }
67
+ >
68
+ {({ isFocused }) => (
69
+ <>
70
+ <span className={twMerge(selected ? 'font-semibold' : 'font-normal', 'block')}>{o.label}</span>
71
+ {selected ? (
72
+ <span className={twMerge(isFocused ? 'text-white' : 'text-primary-500', 'flex items-center')}>
73
+ <Icon name="check" className="h-5 w-5" aria-hidden="true" />
74
+ </span>
75
+ ) : null}
76
+ </>
77
+ )}
78
+ </ListBoxItem>
79
+ )
80
+ })}
81
+ </ListBox>
82
+ </Popover>
83
+ </Select>
84
+ )
85
+ }