@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
package/scripts/build.ts CHANGED
@@ -9,6 +9,7 @@ interface RegistryFile {
9
9
 
10
10
  interface RegistryEntry {
11
11
  name: string
12
+ dependencies?: string[]
12
13
  files: RegistryFile[]
13
14
  }
14
15
 
@@ -19,6 +20,7 @@ interface OutputFile {
19
20
 
20
21
  interface OutputEntry {
21
22
  name: string
23
+ dependencies?: string[]
22
24
  files: OutputFile[]
23
25
  }
24
26
 
@@ -29,14 +31,19 @@ const registry: RegistryEntry[] = JSON.parse(await readFile(resolve(packageRoot,
29
31
  const output: OutputEntry[] = await Promise.all(
30
32
  registry.map(async (entry) => {
31
33
  const files = await Promise.all(
32
- entry.files.map(async (file) => ({
33
- name: `${file.type}/${basename(file.path)}`,
34
- content: await readFile(resolve(packageRoot, file.path), 'utf-8')
35
- }))
34
+ entry.files.map(async (file) => {
35
+ const content = await readFile(resolve(packageRoot, file.path), 'utf-8')
36
+ const header = `// From @maestro-js/components\n// Install with: pnpm maestro components:add ${entry.name} --directory app\n\n`
37
+ return {
38
+ name: `${file.type}/${basename(file.path)}`,
39
+ content: header + content
40
+ }
41
+ })
36
42
  )
37
43
 
38
44
  return {
39
45
  name: entry.name,
46
+ ...(entry.dependencies?.length && { dependencies: entry.dependencies }),
40
47
  files
41
48
  }
42
49
  })
@@ -86,7 +86,7 @@ function AlertDialog_({
86
86
  isDismissable={isDismissable}
87
87
  isKeyboardDismissDisabled={!isDismissable}
88
88
  className={tw(
89
- 'fixed inset-0 z-[4000] flex items-center justify-center bg-black/70 backdrop-blur-lg',
89
+ 'fixed inset-0 z-[4000] flex items-center justify-center bg-black/25 backdrop-blur-sm',
90
90
  'transition-opacity duration-200 ease-out',
91
91
  'data-[entering]:opacity-0',
92
92
  'data-[exiting]:opacity-0'
@@ -100,14 +100,14 @@ function AlertDialog_({
100
100
  'data-[exiting]:scale-95 data-[exiting]:opacity-0 data-[exiting]:translate-y-2'
101
101
  )}
102
102
  >
103
- <Dialog className={tw('outline-none bg-white rounded-lg shadow-2xl w-full', className)} role="alertdialog">
103
+ <Dialog className={tw('outline-none bg-white rounded-xl shadow-xl ring-1 ring-black/5 w-full', className)} role="alertdialog">
104
104
  {({ close }) => (
105
105
  <AlertDialogContext.Provider value={{ close }}>
106
106
  <ButtonGroupContext.Provider
107
107
  value={{ orientation: 'horizontal', alignment: 'center', fullWidth: true, className: 'flex-nowrap' }}
108
108
  >
109
109
  <ButtonContext.Provider value={{ shape: 'rectangle', variant: 'soft', fullWidth: true }}>
110
- <div className={tw('border rounded-lg p-6', styles.container)}>
110
+ <div className={tw('border rounded-xl p-6', styles.container)}>
111
111
  <div className="flex flex-col items-center text-center">
112
112
  {displayIcon && (
113
113
  <div className={tw('w-12 h-12 rounded-xl flex items-center justify-center mb-4', styles.iconBg)}>
@@ -22,7 +22,7 @@ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
22
22
  <div
23
23
  ref={ref}
24
24
  className={tw(
25
- 'size-10 relative inline-flex items-center justify-center overflow-hidden font-medium text-white select-none',
25
+ 'size-10 relative inline-flex items-center justify-center overflow-hidden font-medium text-white select-none ring-1 ring-black/5',
26
26
  !showImage && 'bg-neutral-300',
27
27
  shape === 'circle' && 'rounded-full',
28
28
  shape === 'rounded' && 'rounded',
@@ -56,6 +56,9 @@ export const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
56
56
  className: tw(fullWidth && 'w-full', className)
57
57
  })}
58
58
  >
59
+ {(size === 'xs' || size === 'sm' || shape === 'circle') && (
60
+ <span className="absolute left-1/2 top-1/2 size-12 -translate-x-1/2 -translate-y-1/2" aria-hidden="true" />
61
+ )}
59
62
  <span className={tw('inline-flex items-center gap-2', showSpinner && 'opacity-0')}>{children}</span>
60
63
  <span
61
64
  aria-hidden={!showSpinner ? true : undefined}
@@ -74,6 +74,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
74
74
  ref={ref}
75
75
  data-pending={isPending}
76
76
  onClick={onClick}
77
+ {...(isToggled != null ? { 'aria-pressed': isToggled } : {})}
77
78
  className={composeTailwindRenderProps(
78
79
  props.className,
79
80
  tw(getButtonClasses({ variant, color, size, shape, isToggled }), fullWidth && 'w-full')
@@ -81,6 +82,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
81
82
  >
82
83
  {composeRenderProps(props.children, (children) => (
83
84
  <>
85
+ {(size === 'xs' || size === 'sm' || shape === 'circle') && (
86
+ <span className="absolute left-1/2 top-1/2 size-12 -translate-x-1/2 -translate-y-1/2" aria-hidden="true" />
87
+ )}
84
88
  <span className={tw('inline-flex items-center gap-2', showSpinner && 'opacity-0')}>{children}</span>
85
89
  <span
86
90
  aria-hidden={!showSpinner ? true : undefined}
@@ -78,7 +78,7 @@ export function PlainCheckbox(props: PlainCheckboxProps) {
78
78
  isDisabled={props.isDisabled}
79
79
  isInvalid={props.isInvalid || validationInvalid}
80
80
  aria-label={props['aria-label']}
81
- className="group inline-flex items-center focus:outline-none [&>div]:focus-visible:ring-2 [&>div]:focus-visible:ring-primary-500/20 [&>div]:focus-visible:ring-offset-2"
81
+ className="group inline-flex items-center focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
82
82
  >
83
83
  {({ isSelected, isIndeterminate, isDisabled }) => (
84
84
  <>
@@ -139,7 +139,7 @@ export function LabeledCheckbox(props: LabeledCheckboxProps) {
139
139
  isDisabled={props.isDisabled}
140
140
  isInvalid={isInvalid}
141
141
  aria-describedby={helpTextId}
142
- className="group relative flex items-start gap-3 select-none focus:outline-none [&>div:first-child>div]:focus-visible:ring-2 [&>div:first-child>div]:focus-visible:ring-primary-500/20 [&>div:first-child>div]:focus-visible:ring-offset-2"
142
+ className="group relative flex items-start gap-3 select-none focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
143
143
  >
144
144
  {({ isSelected, isIndeterminate, isDisabled }) => (
145
145
  <>
@@ -191,16 +191,16 @@ function CheckboxBox({
191
191
  return (
192
192
  <div
193
193
  className={tw(
194
- 'relative h-5 w-5 rounded border transition-all duration-50',
195
- isSelected || isIndeterminate ? 'bg-primary-500 border-primary-500' : 'bg-white border-neutral-300',
194
+ 'relative size-5 rounded border transition-colors duration-200',
195
+ isSelected || isIndeterminate ? 'bg-primary-600 border-primary-600' : 'bg-white border-neutral-300',
196
196
  !isDisabled && !(isSelected || isIndeterminate) && 'group-hovered:border-neutral-400',
197
- isDisabled && 'opacity-50 cursor-not-allowed'
197
+ isDisabled && 'cursor-not-allowed border-neutral-300 bg-neutral-100'
198
198
  )}
199
199
  >
200
200
  {isIndeterminate ? (
201
- <Icon name="minus" className="h-5/6 absolute inset-0 m-auto aspect-square text-white" />
201
+ <Icon name="minus" className="h-5/6 absolute inset-0 m-auto aspect-square stroke-white text-white" />
202
202
  ) : (
203
- <Icon name="check" className="h-5/6 absolute inset-0 m-auto aspect-square text-white" />
203
+ <Icon name="check" className="h-5/6 absolute inset-0 m-auto aspect-square stroke-white text-white" />
204
204
  )}
205
205
  </div>
206
206
  )
@@ -60,7 +60,7 @@ export function Chip(props: ChipProps) {
60
60
  className={tw(
61
61
  'inline-flex items-center justify-center rounded-full transition-colors translate-x-1',
62
62
  removeBtnSizeClasses[size],
63
- !isDisabled && 'hover:bg-black/10',
63
+ !isDisabled && 'hover:bg-neutral-950/10',
64
64
  isDisabled && 'cursor-not-allowed'
65
65
  )}
66
66
  aria-label="Remove"
@@ -146,13 +146,13 @@ const darkHoverColorClasses = {
146
146
  }
147
147
 
148
148
  const removeBtnSizeClasses = {
149
- sm: 'h-4 w-4 -m-0.5 p-0.5',
150
- md: 'h-5 w-5 -m-1 p-1',
151
- lg: 'h-6 w-6 -m-1.5 p-1.5'
149
+ sm: 'size-4 -m-0.5 p-0.5',
150
+ md: 'size-5 -m-1 p-1',
151
+ lg: 'size-6 -m-1.5 p-1.5'
152
152
  }
153
153
 
154
154
  const removeIconSizeClasses = {
155
- sm: 'h-2 w-2',
156
- md: 'h-3 w-3',
157
- lg: 'h-4 w-4'
155
+ sm: 'size-2',
156
+ md: 'size-3',
157
+ lg: 'size-4'
158
158
  }
@@ -71,9 +71,9 @@ const sizeClasses: Record<ContainerSize, string> = {
71
71
  }
72
72
 
73
73
  const elevationClasses: Record<string, string> = {
74
- '0': 'bg-white border border-neutral-200',
75
- '-1': 'bg-neutral-50 border border-neutral-200',
76
- '-2': 'bg-neutral-100 border border-neutral-300',
74
+ '0': 'bg-white ring-1 ring-neutral-950/5',
75
+ '-1': 'bg-neutral-50 ring-1 ring-neutral-950/5',
76
+ '-2': 'bg-neutral-100 ring-1 ring-neutral-950/10',
77
77
  inherit: ''
78
78
  }
79
79
 
@@ -0,0 +1,161 @@
1
+ import * as React from 'react'
2
+ import {
3
+ RangeCalendar,
4
+ CalendarGrid,
5
+ CalendarCell,
6
+ Heading,
7
+ CalendarGridHeader,
8
+ CalendarHeaderCell,
9
+ CalendarGridBody,
10
+ Button,
11
+ I18nProvider
12
+ } from 'react-aria-components'
13
+ import { twMerge } from 'tailwind-merge'
14
+ import { parseDate, type CalendarDate, type DateValue } from '@internationalized/date'
15
+ import type { Iso } from 'iso-fns'
16
+
17
+ export type DateRangeRootAdditionalProps = {
18
+ defaultStart: Iso.Date | null
19
+ defaultEnd: Iso.Date | null
20
+ shouldDisableDate: (date: string) => boolean
21
+ shouldDisableStartDate: (date: string) => boolean
22
+ shouldDisableEndDate: (date: string) => boolean
23
+ }
24
+
25
+ type DateRangePickerProps = {
26
+ start: Iso.Date | null
27
+ end: Iso.Date | null
28
+ onStartChange: (value: Iso.Date | null, field: 'start' | 'end' | null) => void
29
+ onEndChange: (value: Iso.Date | null, field: 'start' | 'end' | null) => void
30
+ currentField: 'start' | 'end' | null
31
+ shouldDisableDate?: (date: string) => boolean
32
+ shouldDisableStartDate?: (date: string) => boolean
33
+ shouldDisableEndDate?: (date: string) => boolean
34
+ }
35
+
36
+ export function DateRangePicker(props: DateRangePickerProps) {
37
+ const ariaValue = React.useMemo(() => {
38
+ if (!props.start || !props.end) return null
39
+ try {
40
+ return { start: parseDate(props.start), end: parseDate(props.end) }
41
+ } catch {
42
+ return null
43
+ }
44
+ }, [props.start, props.end])
45
+
46
+ const handleChange = (range: { start: CalendarDate; end: CalendarDate } | null) => {
47
+ if (!range) {
48
+ props.onStartChange(null, null)
49
+ props.onEndChange(null, null)
50
+ return
51
+ }
52
+ const start = range.start.toString() as Iso.Date
53
+ const end = range.end.toString() as Iso.Date
54
+ props.onStartChange(start, 'end')
55
+ props.onEndChange(end, null)
56
+ }
57
+
58
+ const isDateUnavailable = React.useCallback(
59
+ (date: DateValue) => {
60
+ const dateStr = date.toString()
61
+ return props.shouldDisableDate?.(dateStr) ?? false
62
+ },
63
+ [props.shouldDisableDate]
64
+ )
65
+
66
+ return (
67
+ <I18nProvider locale="en">
68
+ <RangeCalendar
69
+ value={ariaValue}
70
+ onChange={handleChange}
71
+ isDateUnavailable={isDateUnavailable}
72
+ visibleDuration={{ months: 2 }}
73
+ className="text-sm"
74
+ >
75
+ <header className="flex items-center justify-between mb-2 px-1">
76
+ <Button slot="previous" className="p-1 hover:bg-neutral-100 rounded cursor-pointer">
77
+ <ChevronLeft />
78
+ </Button>
79
+ <Heading className="text-sm font-semibold text-neutral-900 flex-1 text-center" />
80
+ <Button slot="next" className="p-1 hover:bg-neutral-100 rounded cursor-pointer">
81
+ <ChevronRight />
82
+ </Button>
83
+ </header>
84
+ <div className="flex gap-4">
85
+ <CalendarGrid>
86
+ <CalendarGridHeader>
87
+ {(day) => (
88
+ <CalendarHeaderCell className="text-xs text-neutral-500 font-medium w-8 h-8">{day}</CalendarHeaderCell>
89
+ )}
90
+ </CalendarGridHeader>
91
+ <CalendarGridBody>
92
+ {(date) => (
93
+ <CalendarCell
94
+ date={date}
95
+ className={({ isSelected, isSelectionStart, isSelectionEnd, isOutsideMonth, isDisabled, isUnavailable }) =>
96
+ twMerge(
97
+ 'w-8 h-8 text-sm rounded cursor-pointer flex items-center justify-center',
98
+ 'hover:bg-neutral-100',
99
+ isOutsideMonth && 'text-neutral-300',
100
+ (isDisabled || isUnavailable) && 'text-neutral-300 cursor-not-allowed',
101
+ isSelected && !isSelectionStart && !isSelectionEnd && 'bg-primary-500/20',
102
+ (isSelectionStart || isSelectionEnd) && 'bg-primary-500 text-white hover:bg-primary-500'
103
+ )
104
+ }
105
+ />
106
+ )}
107
+ </CalendarGridBody>
108
+ </CalendarGrid>
109
+ <CalendarGrid offset={{ months: 1 }}>
110
+ <CalendarGridHeader>
111
+ {(day) => (
112
+ <CalendarHeaderCell className="text-xs text-neutral-500 font-medium w-8 h-8">{day}</CalendarHeaderCell>
113
+ )}
114
+ </CalendarGridHeader>
115
+ <CalendarGridBody>
116
+ {(date) => (
117
+ <CalendarCell
118
+ date={date}
119
+ className={({ isSelected, isSelectionStart, isSelectionEnd, isOutsideMonth, isDisabled, isUnavailable }) =>
120
+ twMerge(
121
+ 'w-8 h-8 text-sm rounded cursor-pointer flex items-center justify-center',
122
+ 'hover:bg-neutral-100',
123
+ isOutsideMonth && 'text-neutral-300',
124
+ (isDisabled || isUnavailable) && 'text-neutral-300 cursor-not-allowed',
125
+ isSelected && !isSelectionStart && !isSelectionEnd && 'bg-primary-500/20',
126
+ (isSelectionStart || isSelectionEnd) && 'bg-primary-500 text-white hover:bg-primary-500'
127
+ )
128
+ }
129
+ />
130
+ )}
131
+ </CalendarGridBody>
132
+ </CalendarGrid>
133
+ </div>
134
+ </RangeCalendar>
135
+ </I18nProvider>
136
+ )
137
+ }
138
+
139
+ function ChevronLeft() {
140
+ return (
141
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
142
+ <path
143
+ fillRule="evenodd"
144
+ d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
145
+ clipRule="evenodd"
146
+ />
147
+ </svg>
148
+ )
149
+ }
150
+
151
+ function ChevronRight() {
152
+ return (
153
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
154
+ <path
155
+ fillRule="evenodd"
156
+ d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
157
+ clipRule="evenodd"
158
+ />
159
+ </svg>
160
+ )
161
+ }
@@ -1,4 +1,6 @@
1
1
  import React from 'react'
2
+ import type { To, RelativeRoutingType } from 'react-router'
3
+ import { useNavigate } from 'react-router'
2
4
  import { Modal, ModalOverlay, Dialog as AriaDialog, Heading } from 'react-aria-components'
3
5
  import { tw } from '../utils/tw'
4
6
  import { Button } from './button'
@@ -7,8 +9,10 @@ export type DialogProps = {
7
9
  children?: React.ReactNode
8
10
  title?: React.ReactNode
9
11
  isOpen?: boolean
10
- onOpenChange?: (isOpen: boolean) => void
11
- size?: 'sm' | 'lg'
12
+ close?:
13
+ | ((isOpen: boolean) => void)
14
+ | { to: To; replace?: boolean; relative?: RelativeRoutingType; reloadDocument?: boolean }
15
+ size?: 'xs' | 'sm' | 'lg'
12
16
  className?: string
13
17
  isDismissable?: boolean
14
18
  actions?: React.ReactNode
@@ -24,12 +28,22 @@ function Dialog_({
24
28
  children,
25
29
  title,
26
30
  isOpen = false,
27
- onOpenChange,
31
+ close,
28
32
  size = 'sm',
29
33
  className,
30
34
  isDismissable = false,
31
35
  actions
32
36
  }: DialogProps) {
37
+ const navigate = useNavigate()
38
+
39
+ const onOpenChange = close
40
+ ? typeof close === 'function'
41
+ ? close
42
+ : (open: boolean) => {
43
+ if (!open) navigate(close.to, { replace: close.replace, relative: close.relative })
44
+ }
45
+ : undefined
46
+
33
47
  return (
34
48
  <ModalOverlay
35
49
  isOpen={isOpen}
@@ -37,7 +51,7 @@ function Dialog_({
37
51
  isDismissable={isDismissable}
38
52
  isKeyboardDismissDisabled={!isDismissable}
39
53
  className={tw(
40
- 'fixed inset-0 z-[2000] flex items-center justify-center bg-black/70 backdrop-blur-lg',
54
+ 'fixed inset-0 z-[2000] flex items-center justify-center bg-black/25 backdrop-blur-sm',
41
55
  'transition-opacity duration-300 ease-in-out',
42
56
  'data-[entering]:opacity-0',
43
57
  'data-[exiting]:opacity-0'
@@ -46,7 +60,7 @@ function Dialog_({
46
60
  <Modal
47
61
  className={tw(
48
62
  'w-[calc(100%-4rem)] m-auto',
49
- size === 'lg' ? 'max-w-[1080px]' : 'max-w-[640px]',
63
+ size === 'lg' ? 'max-w-[1080px]' : size === 'xs' ? 'max-w-[400px]' : 'max-w-[640px]',
50
64
  'transition-all duration-300 ease-in-out',
51
65
  'data-[entering]:scale-95 data-[entering]:opacity-0',
52
66
  'data-[exiting]:scale-95 data-[exiting]:opacity-0'
@@ -54,7 +68,7 @@ function Dialog_({
54
68
  >
55
69
  <AriaDialog
56
70
  className={tw(
57
- 'w-full max-h-[calc(100vh-6rem)] bg-white shadow-xl outline-none rounded-lg',
71
+ 'w-full max-h-[calc(100vh-6rem)] bg-white shadow-xl ring-1 ring-black/5 outline-none rounded-xl',
58
72
  'flex flex-col overflow-x-hidden overflow-y-auto',
59
73
  className
60
74
  )}
@@ -63,7 +77,7 @@ function Dialog_({
63
77
  <DialogContext.Provider value={{ close }}>
64
78
  <div className="flex items-start justify-between gap-4 pt-6 px-6">
65
79
  {title ? (
66
- <Heading slot="title" className="flex-1 text-xl font-semibold text-neutral-900">
80
+ <Heading slot="title" className="flex-1 text-lg font-semibold text-neutral-900">
67
81
  {title}
68
82
  </Heading>
69
83
  ) : (
@@ -85,7 +85,7 @@ function DisclosureHeader({
85
85
  name="chevron-right"
86
86
  className={tw(
87
87
  'shrink-0 transition-transform duration-200 ease-in-out',
88
- size === 'md' || size === 'lg' ? 'w-4 h-4' : 'w-3 h-3',
88
+ size === 'md' || size === 'lg' ? 'size-4' : 'size-3',
89
89
  isExpanded && 'rotate-90',
90
90
  isDisabled ? 'text-neutral-300' : 'text-neutral-500'
91
91
  )}
@@ -1,4 +1,6 @@
1
1
  import React from 'react'
2
+ import type { To, RelativeRoutingType } from 'react-router'
3
+ import { useNavigate } from 'react-router'
2
4
  import { Drawer as VaulDrawer } from 'vaul'
3
5
  import { tw } from '../utils/tw'
4
6
 
@@ -6,7 +8,9 @@ export type DrawerProps = {
6
8
  children?: React.ReactNode
7
9
  title?: React.ReactNode
8
10
  isOpen?: boolean
9
- onOpenChange?: (isOpen: boolean) => void
11
+ close?:
12
+ | ((isOpen: boolean) => void)
13
+ | { to: To; replace?: boolean; relative?: RelativeRoutingType; reloadDocument?: boolean }
10
14
  size?: 'sm' | 'md' | 'lg'
11
15
  className?: string
12
16
  isDismissable?: boolean
@@ -19,7 +23,7 @@ export function Drawer({
19
23
  children,
20
24
  title,
21
25
  isOpen = false,
22
- onOpenChange,
26
+ close,
23
27
  size = 'md',
24
28
  className,
25
29
  isDismissable = true,
@@ -27,10 +31,20 @@ export function Drawer({
27
31
  actions,
28
32
  contentsContainerClassName
29
33
  }: DrawerProps) {
34
+ const navigate = useNavigate()
35
+
36
+ const onOpenChange = close
37
+ ? typeof close === 'function'
38
+ ? close
39
+ : (open: boolean) => {
40
+ if (!open) navigate(close.to, { replace: close.replace, relative: close.relative })
41
+ }
42
+ : undefined
43
+
30
44
  return (
31
45
  <VaulDrawer.Root open={isOpen} onOpenChange={onOpenChange} direction={position} dismissible={isDismissable}>
32
46
  <VaulDrawer.Portal>
33
- <VaulDrawer.Overlay className="fixed inset-0 z-49 bg-black/70 backdrop-blur-lg" />
47
+ <VaulDrawer.Overlay className="fixed inset-0 z-49 bg-black/25 backdrop-blur-sm" />
34
48
  <VaulDrawer.Content
35
49
  className={tw(
36
50
  'flex h-auto flex-col text-sm group/drawer-content fixed z-50',
@@ -50,7 +64,7 @@ export function Drawer({
50
64
  {isDismissable ? (
51
65
  <div
52
66
  className={tw(
53
- 'absolute bg-neutral-200 rounded-full shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block',
67
+ 'absolute bg-neutral-300 rounded-full shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block',
54
68
  'group-data-[vaul-drawer-direction=left]/drawer-content:h-25 group-data-[vaul-drawer-direction=left]/drawer-content:w-1 group-data-[vaul-drawer-direction=left]/drawer-content:right-2 group-data-[vaul-drawer-direction=left]/drawer-content:top-[calc(50%-50px)]',
55
69
  'group-data-[vaul-drawer-direction=right]/drawer-content:h-25 group-data-[vaul-drawer-direction=right]/drawer-content:w-1 group-data-[vaul-drawer-direction=right]/drawer-content:left-2 group-data-[vaul-drawer-direction=right]/drawer-content:top-[calc(50%-50px)]',
56
70
  'group-data-[vaul-drawer-direction=top]/drawer-content:w-25 group-data-[vaul-drawer-direction=top]/drawer-content:h-1 group-data-[vaul-drawer-direction=top]/drawer-content:bottom-2 group-data-[vaul-drawer-direction=top]/drawer-content:left-[calc(50%-50px)]',
@@ -59,7 +73,7 @@ export function Drawer({
59
73
  />
60
74
  ) : null}
61
75
 
62
- <div className="bg-white size-full pt-6 px-6 pb-6 rounded-md shadow-xl">
76
+ <div className="bg-white size-full pt-6 px-6 pb-6 rounded-xl shadow-xl ring-1 ring-black/5">
63
77
  <div className="flex items-start justify-between gap-4 pb-5">
64
78
  {title ? (
65
79
  <VaulDrawer.Title className="flex-1 text-xl font-semibold text-neutral-900">{title}</VaulDrawer.Title>
@@ -1,19 +1,20 @@
1
+ import { forwardRef } from 'react'
1
2
  import {
2
3
  type FormProps as ReactRouterFormProps,
3
4
  Form as ReactRouterForm,
4
5
  data,
5
6
  type ClientActionFunctionArgs
6
7
  } from 'react-router'
7
- import { HeadlessForm } from '@maestro-js/form'
8
+ import { HeadlessForm, type StandardSchema, type ParseFormDataOptions, type ParseFormDataResult } from '@maestro-js/form'
8
9
  import { serverOnly$ } from 'vite-env-only/macros'
9
10
 
10
- function _Form({ children, ...props }: ReactRouterFormProps) {
11
+ const _Form = forwardRef<HTMLFormElement, ReactRouterFormProps>(function Form({ children, ...props }, ref) {
11
12
  return (
12
- <HeadlessForm as={ReactRouterForm} {...props}>
13
+ <HeadlessForm as={ReactRouterForm} ref={ref} {...props}>
13
14
  {children}
14
15
  </HeadlessForm>
15
16
  )
16
- }
17
+ })
17
18
 
18
19
  export const Form = Object.assign(_Form, {
19
20
  HiddenInput: HeadlessForm.HiddenInput,
@@ -23,7 +24,28 @@ export const Form = Object.assign(_Form, {
23
24
  useTopLevelFormValidation: HeadlessForm.useTopLevelFormValidation,
24
25
  useReset: HeadlessForm.useReset,
25
26
  clientSafeParse: HeadlessForm.parseFormDataClientSide,
26
- serverSafeParse: HeadlessForm.parseFormData,
27
+ serverSafeParse<T>(
28
+ schema: StandardSchema<T, T>,
29
+ formData: FormData | Promise<FormData>,
30
+ options?: {
31
+ handleFile: ParseFormDataOptions<T>['handleFile']
32
+ }
33
+ ): Promise<ParseFormDataResult<T>> {
34
+ return HeadlessForm.parseFormData(schema, formData, {
35
+ ...options,
36
+ jsonSchemaLibraryOptions: {
37
+ unrepresentable: 'any',
38
+ override: (ctx) => {
39
+ const meta = ctx.zodSchema.meta()
40
+ const custom = meta?.jsonSchema
41
+
42
+ if (custom && typeof custom === 'object') {
43
+ Object.assign(ctx.jsonSchema, custom)
44
+ }
45
+ }
46
+ }
47
+ })
48
+ },
27
49
  validationError: serverOnly$(validationError) as typeof validationError,
28
50
  customValidationError: serverOnly$(customValidationError) as typeof customValidationError,
29
51
  composeValidators: HeadlessForm.composeValidators,
@@ -85,17 +85,16 @@ const FieldGroup = React.forwardRef<HTMLDivElement, FieldGroupProps>(
85
85
  <Group
86
86
  ref={ref}
87
87
  className={tw(
88
- 'relative overflow-hidden border transition-all duration-200 max-w-96',
89
- 'focus-within:ring-1',
88
+ 'relative overflow-hidden ring-1 shadow-sm transition-all duration-200 max-w-96',
89
+ 'focus-within:outline-2 focus-within:-outline-offset-1',
90
90
  shape,
91
- disabled && 'border-neutral-100 bg-neutral-50 text-neutral-400 cursor-not-allowed',
92
- opts.isInvalid && 'border-negative-200 bg-negative-50 focus-within:border-negative-500 focus-within:ring-negative-500/20',
93
- opts.hasWarning &&
94
- 'border-notice-200 bg-notice-50 focus-within:border-notice-500 focus-within:ring-notice-500/20',
91
+ disabled && 'ring-neutral-200 bg-neutral-50 text-neutral-400 shadow-none cursor-not-allowed',
92
+ opts.isInvalid && 'ring-negative-300 bg-negative-50 focus-within:outline-negative-500',
93
+ opts.hasWarning && 'ring-notice-300 bg-notice-50 focus-within:outline-notice-500',
95
94
  !opts.isInvalid &&
96
95
  !opts.hasWarning &&
97
96
  !disabled &&
98
- 'border-neutral-200 hover:border-neutral-300 focus-within:border-primary-500 focus-within:ring-primary-500/20',
97
+ 'ring-black/10 hover:ring-black/20 focus-within:outline-primary-500',
99
98
  className
100
99
  )}
101
100
  onClick={onClick}
@@ -114,7 +113,7 @@ const FieldInput = React.forwardRef<HTMLInputElement, InputProps>((props, ref) =
114
113
  className={composeTailwindRenderProps(
115
114
  props.className,
116
115
  tw(
117
- 'flex-1 bg-transparent px-3 py-2 border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400',
116
+ 'flex-1 bg-transparent px-3 py-2 border-0 focus:ring-0 focus:outline-none text-sm max-sm:text-base text-neutral-900 placeholder:text-neutral-400',
118
117
  (props.disabled || props.readOnly) && 'cursor-not-allowed'
119
118
  )
120
119
  )}