@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3260.1.dc719cac84

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -1,52 +1,262 @@
1
+ "use client"
2
+
1
3
  import * as React from 'react'
4
+ import { AlertCircle, CheckCircle2, Info, Rocket, X, AlertTriangle } from 'lucide-react'
2
5
  import { cva, type VariantProps } from 'class-variance-authority'
3
6
 
4
7
  import { cn } from '@open-mercato/shared/lib/utils'
5
8
 
9
+ export type AlertStatus = 'error' | 'warning' | 'success' | 'information' | 'feature'
10
+ export type AlertStyle = 'filled' | 'light' | 'lighter' | 'stroke'
11
+ export type AlertSize = 'xs' | 'sm' | 'default'
12
+
13
+ /**
14
+ * Legacy `variant` values from the pre-Figma-`169:2358` API. Kept for BC —
15
+ * mapped to `status` inside the Alert component so existing call sites do
16
+ * not need migration. The Figma `light` look (`#fecaca` saturated tint
17
+ * for error → `state/{x}/light` token family) is heavier than the
18
+ * pre-Figma look; legacy call sites that want the softer pre-Figma
19
+ * appearance should switch explicitly to `style="lighter"`.
20
+ */
21
+ type LegacyVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info'
22
+
23
+ const legacyVariantToStatus: Record<LegacyVariant, AlertStatus> = {
24
+ default: 'information',
25
+ destructive: 'error',
26
+ success: 'success',
27
+ warning: 'warning',
28
+ info: 'information',
29
+ }
30
+
6
31
  const alertVariants = cva(
7
- "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-8",
32
+ 'relative flex w-full gap-2 transition-colors',
8
33
  {
9
34
  variants: {
10
- variant: {
11
- default: 'border-border bg-background text-foreground [&>svg]:text-foreground',
12
- destructive:
13
- 'border-status-error-border bg-status-error-bg text-status-error-text [&>svg]:text-status-error-icon',
14
- success:
15
- 'border-status-success-border bg-status-success-bg text-status-success-text [&>svg]:text-status-success-icon',
16
- warning:
17
- 'border-status-warning-border bg-status-warning-bg text-status-warning-text [&>svg]:text-status-warning-icon',
18
- info:
19
- 'border-status-info-border bg-status-info-bg text-status-info-text [&>svg]:text-status-info-icon',
35
+ status: {
36
+ error: '',
37
+ warning: '',
38
+ success: '',
39
+ information: '',
40
+ feature: '',
41
+ },
42
+ style: {
43
+ filled: '',
44
+ light: '',
45
+ lighter: '',
46
+ stroke: '',
47
+ },
48
+ size: {
49
+ // Figma X-Small: px-[8px] py-[6px], radius-8, Paragraph/X Small (12/16)
50
+ xs: 'min-h-8 items-center rounded-lg px-2 py-1.5 text-xs leading-4 tracking-tight',
51
+ // Figma Small: px-[10px] py-[8px], radius-8, Paragraph/Small (14/20)
52
+ sm: 'min-h-9 items-center rounded-lg px-2.5 py-2 text-sm leading-5 tracking-tight',
53
+ // Figma Large: px-[12px] py-[10px], radius-12, Paragraph/Small (14/20) multi-line
54
+ default: 'items-start rounded-xl px-3 py-2.5 text-sm leading-5 tracking-tight',
20
55
  },
21
56
  },
57
+ compoundVariants: [
58
+ // === filled (Figma: bg = state/{x}/base, text = static-white) ===
59
+ { status: 'error', style: 'filled', className: 'border border-transparent bg-status-error-icon text-white' },
60
+ { status: 'warning', style: 'filled', className: 'border border-transparent bg-status-warning-icon text-white' },
61
+ { status: 'success', style: 'filled', className: 'border border-transparent bg-status-success-icon text-white' },
62
+ { status: 'information', style: 'filled', className: 'border border-transparent bg-status-info-icon text-white' },
63
+ { status: 'feature', style: 'filled', className: 'border border-transparent bg-status-neutral-icon text-white' },
64
+ // === light (Figma: bg = state/{x}/light, text = text/strong-950 = #171717 black) ===
65
+ { status: 'error', style: 'light', className: 'border border-transparent bg-status-error-border text-foreground' },
66
+ { status: 'warning', style: 'light', className: 'border border-transparent bg-status-warning-border text-foreground' },
67
+ { status: 'success', style: 'light', className: 'border border-transparent bg-status-success-border text-foreground' },
68
+ { status: 'information', style: 'light', className: 'border border-transparent bg-status-info-border text-foreground' },
69
+ { status: 'feature', style: 'light', className: 'border border-transparent bg-status-neutral-border text-foreground' },
70
+ // === lighter (Figma: bg = state/{x}/lighter, text = text/strong-950 = #171717 black) ===
71
+ { status: 'error', style: 'lighter', className: 'border border-transparent bg-status-error-bg text-foreground' },
72
+ { status: 'warning', style: 'lighter', className: 'border border-transparent bg-status-warning-bg text-foreground' },
73
+ { status: 'success', style: 'lighter', className: 'border border-transparent bg-status-success-bg text-foreground' },
74
+ { status: 'information', style: 'lighter', className: 'border border-transparent bg-status-info-bg text-foreground' },
75
+ { status: 'feature', style: 'lighter', className: 'border border-transparent bg-status-neutral-bg text-foreground' },
76
+ // === stroke (Figma: bg = white, border = stroke/soft-200, text = strong-950, shadow = regular-shadow/medium) ===
77
+ { style: 'stroke', className: 'border border-border bg-background text-foreground shadow-lg' },
78
+ ],
22
79
  defaultVariants: {
23
- variant: 'default',
80
+ status: 'information',
81
+ style: 'light',
82
+ size: 'sm',
24
83
  },
25
- }
84
+ },
26
85
  )
27
86
 
28
- type AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
87
+ // Dismiss button (X) color per Figma: text/strong-950 (#171717 black) at
88
+ // `opacity-40` for light/lighter/stroke — matches the Figma `close-line`
89
+ // icon which is the dark icon-strong-950 fill rendered at 40% alpha
90
+ // (visually ≈ #5c5c5c gray). White at full opacity for filled so it
91
+ // stays legible over the saturated bg.
92
+ const alertDismissVariants = cva(
93
+ 'shrink-0 rounded-md p-0.5 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
94
+ {
95
+ variants: {
96
+ style: {
97
+ filled: 'text-white opacity-90 hover:opacity-100',
98
+ light: 'text-foreground opacity-40 hover:opacity-100',
99
+ lighter: 'text-foreground opacity-40 hover:opacity-100',
100
+ stroke: 'text-foreground opacity-40 hover:opacity-100',
101
+ },
102
+ },
103
+ defaultVariants: { style: 'light' },
104
+ },
105
+ )
29
106
 
30
- const Alert = React.forwardRef<HTMLDivElement, AlertProps>(({ className, variant, ...props }, ref) => (
31
- <div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
32
- ))
107
+ const alertIconBadgeVariants = cva(
108
+ 'inline-flex shrink-0 items-center justify-center rounded-full text-white',
109
+ {
110
+ variants: {
111
+ status: {
112
+ error: 'bg-status-error-icon',
113
+ warning: 'bg-status-warning-icon',
114
+ success: 'bg-status-success-icon',
115
+ information: 'bg-status-info-icon',
116
+ feature: 'bg-status-neutral-icon',
117
+ },
118
+ size: {
119
+ xs: 'size-4 [&>svg]:size-2.5',
120
+ sm: 'size-5 [&>svg]:size-3',
121
+ default: 'size-6 [&>svg]:size-3.5',
122
+ },
123
+ },
124
+ defaultVariants: { status: 'information', size: 'sm' },
125
+ },
126
+ )
127
+
128
+ const defaultStatusIcons: Record<AlertStatus, React.ComponentType<{ className?: string; 'aria-hidden'?: 'true' }>> = {
129
+ error: AlertCircle,
130
+ warning: AlertTriangle,
131
+ success: CheckCircle2,
132
+ information: Info,
133
+ feature: Rocket,
134
+ }
135
+
136
+ export type AlertProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> &
137
+ Omit<VariantProps<typeof alertVariants>, 'status' | 'style' | 'size'> & {
138
+ status?: AlertStatus
139
+ style?: AlertStyle
140
+ size?: AlertSize
141
+ /** Show the leading icon. Default `true`. */
142
+ showIcon?: boolean
143
+ /** Override the default per-status Lucide icon. */
144
+ icon?: React.ReactNode
145
+ /** Render a trailing close (X) button. Pair with `onDismiss`. */
146
+ dismissible?: boolean
147
+ onDismiss?: () => void
148
+ dismissAriaLabel?: string
149
+ /** Inline action slot (link buttons) rendered to the right of the body. */
150
+ action?: React.ReactNode
151
+ /**
152
+ * @deprecated Use `status` + `style` instead. Kept for BC. Maps the
153
+ * legacy single-prop variants onto `status` and picks up the new
154
+ * `light` + `sm` defaults.
155
+ */
156
+ variant?: LegacyVariant
157
+ }
33
158
 
159
+ const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
160
+ (
161
+ {
162
+ className,
163
+ status,
164
+ style,
165
+ size,
166
+ showIcon = true,
167
+ icon,
168
+ dismissible = false,
169
+ onDismiss,
170
+ dismissAriaLabel = 'Dismiss',
171
+ action,
172
+ variant,
173
+ children,
174
+ ...props
175
+ },
176
+ ref,
177
+ ) => {
178
+ const resolvedStatus: AlertStatus = status ?? (variant ? legacyVariantToStatus[variant] : 'information')
179
+ const resolvedStyle: AlertStyle = style ?? 'light'
180
+ const resolvedSize: AlertSize = size ?? 'sm'
181
+ const IconComponent = defaultStatusIcons[resolvedStatus]
182
+ const rawIcon = icon ?? <IconComponent aria-hidden="true" />
183
+
184
+ let iconSlot: React.ReactNode = null
185
+ if (showIcon) {
186
+ if (resolvedStyle === 'filled') {
187
+ // Filled: plain icon over saturated bg — no badge wrap, inherit text-white.
188
+ iconSlot = (
189
+ <span
190
+ className={cn(
191
+ 'inline-flex shrink-0 items-center justify-center',
192
+ resolvedSize === 'default' ? '[&>svg]:size-5' : '[&>svg]:size-4',
193
+ )}
194
+ data-slot="alert-icon"
195
+ >
196
+ {rawIcon}
197
+ </span>
198
+ )
199
+ } else {
200
+ // light / lighter / stroke: badge wrap with status color bg + white icon.
201
+ iconSlot = (
202
+ <span
203
+ className={alertIconBadgeVariants({ status: resolvedStatus, size: resolvedSize })}
204
+ data-slot="alert-icon-badge"
205
+ data-status={resolvedStatus}
206
+ >
207
+ {rawIcon}
208
+ </span>
209
+ )
210
+ }
211
+ }
212
+
213
+ return (
214
+ <div
215
+ ref={ref}
216
+ role="alert"
217
+ className={cn(
218
+ alertVariants({ status: resolvedStatus, style: resolvedStyle, size: resolvedSize }),
219
+ className,
220
+ )}
221
+ data-slot="alert"
222
+ data-status={resolvedStatus}
223
+ data-style={resolvedStyle}
224
+ {...props}
225
+ >
226
+ {iconSlot}
227
+ <div className="min-w-0 flex-1">{children}</div>
228
+ {action ? <div className="shrink-0">{action}</div> : null}
229
+ {dismissible ? (
230
+ <button
231
+ type="button"
232
+ onClick={onDismiss}
233
+ aria-label={dismissAriaLabel}
234
+ className={alertDismissVariants({ style: resolvedStyle })}
235
+ data-slot="alert-dismiss"
236
+ >
237
+ <X aria-hidden="true" className="size-4" />
238
+ </button>
239
+ ) : null}
240
+ </div>
241
+ )
242
+ },
243
+ )
34
244
  Alert.displayName = 'Alert'
35
245
 
246
+ // Figma "Label/Small": Inter Medium 14 / 20.
36
247
  const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
37
248
  ({ className, ...props }, ref) => (
38
- <h5 ref={ref} className={cn('mb-1 text-sm font-semibold leading-tight', className)} {...props} />
39
- )
249
+ <h5 ref={ref} className={cn('mb-1 text-sm font-medium leading-5', className)} {...props} />
250
+ ),
40
251
  )
41
-
42
252
  AlertTitle.displayName = 'AlertTitle'
43
253
 
254
+ // Figma "Paragraph/Small": Inter Regular 14 / 20.
44
255
  const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
45
256
  ({ className, ...props }, ref) => (
46
- <p ref={ref} className={cn('text-sm leading-relaxed', className)} {...props} />
47
- )
257
+ <p ref={ref} className={cn('text-sm leading-5', className)} {...props} />
258
+ ),
48
259
  )
49
-
50
260
  AlertDescription.displayName = 'AlertDescription'
51
261
 
52
- export { Alert, AlertDescription, AlertTitle, alertVariants }
262
+ export { Alert, AlertDescription, AlertTitle, alertVariants, alertIconBadgeVariants }
@@ -1,67 +1,175 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { DayPicker } from 'react-day-picker'
5
- import type { DayPickerProps } from 'react-day-picker'
4
+ import { DayPicker, useDayPicker } from 'react-day-picker'
5
+ import type { DayPickerProps, CalendarMonth } from 'react-day-picker'
6
6
  import { ChevronLeft, ChevronRight } from 'lucide-react'
7
+ import { format } from 'date-fns'
8
+ import type { Locale } from 'date-fns'
7
9
  import { cn } from '@open-mercato/shared/lib/utils'
8
10
 
9
11
  export type CalendarProps = DayPickerProps
10
12
 
11
- export function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
13
+ function MonthNavButton({
14
+ direction,
15
+ locale,
16
+ }: {
17
+ direction: 'prev' | 'next'
18
+ locale?: Locale
19
+ }) {
20
+ const dayPicker = useDayPicker() as unknown as {
21
+ previousMonth?: Date
22
+ nextMonth?: Date
23
+ goToMonth?: (month: Date) => void
24
+ }
25
+ const target = direction === 'prev' ? dayPicker.previousMonth : dayPicker.nextMonth
26
+ const Icon = direction === 'prev' ? ChevronLeft : ChevronRight
27
+ const targetLabel = format(target ?? new Date(), 'MMMM yyyy', locale ? { locale } : undefined)
28
+ const ariaLabel = `Go to ${direction === 'prev' ? 'previous' : 'next'} month: ${targetLabel}`
29
+ return (
30
+ <button
31
+ type="button"
32
+ disabled={!target}
33
+ aria-label={ariaLabel}
34
+ onClick={() => {
35
+ if (target && dayPicker.goToMonth) dayPicker.goToMonth(target)
36
+ }}
37
+ className={cn(
38
+ 'h-9 w-9 inline-flex items-center justify-center rounded-md shrink-0',
39
+ 'border border-border bg-background text-muted-foreground transition-colors',
40
+ 'hover:bg-accent hover:text-accent-foreground hover:border-input',
41
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
42
+ 'disabled:opacity-30 disabled:pointer-events-none disabled:hover:bg-background',
43
+ )}
44
+ >
45
+ <Icon className="h-4 w-4" aria-hidden="true" />
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function buildMonthCaption(locale: Locale | undefined, totalMonths: number) {
51
+ return function MonthCaption({
52
+ calendarMonth,
53
+ displayIndex,
54
+ }: {
55
+ calendarMonth: CalendarMonth
56
+ displayIndex?: number
57
+ }) {
58
+ const label = format(calendarMonth.date, 'MMMM yyyy', locale ? { locale } : undefined)
59
+ const index = typeof displayIndex === 'number' ? displayIndex : 0
60
+ // For multi-month layouts (e.g. range pickers) only the leftmost month
61
+ // exposes the previous-month chevron and only the rightmost exposes the
62
+ // next-month chevron. Navigation is always global across all visible
63
+ // months, so showing both arrows on every month is confusing.
64
+ const showPrev = index === 0
65
+ const showNext = index === totalMonths - 1
66
+ return (
67
+ <div className="flex items-center justify-between gap-2 mb-3">
68
+ {showPrev ? (
69
+ <MonthNavButton direction="prev" locale={locale} />
70
+ ) : (
71
+ <div className="h-9 w-9 shrink-0" aria-hidden="true" />
72
+ )}
73
+ <div
74
+ className="flex-1 flex items-center justify-center h-9 rounded-md bg-muted px-3 text-sm font-medium"
75
+ aria-live="polite"
76
+ >
77
+ {label}
78
+ </div>
79
+ {showNext ? (
80
+ <MonthNavButton direction="next" locale={locale} />
81
+ ) : (
82
+ <div className="h-9 w-9 shrink-0" aria-hidden="true" />
83
+ )}
84
+ </div>
85
+ )
86
+ }
87
+ }
88
+
89
+ export function Calendar({
90
+ className,
91
+ classNames,
92
+ showOutsideDays = true,
93
+ fixedWeeks = true,
94
+ locale,
95
+ components,
96
+ numberOfMonths = 1,
97
+ pagedNavigation = true,
98
+ ...props
99
+ }: CalendarProps) {
100
+ const monthCaption = React.useMemo(
101
+ () => buildMonthCaption(locale as Locale | undefined, numberOfMonths),
102
+ [locale, numberOfMonths],
103
+ )
12
104
  return (
13
105
  <DayPicker
14
106
  showOutsideDays={showOutsideDays}
107
+ fixedWeeks={fixedWeeks}
108
+ pagedNavigation={pagedNavigation}
109
+ locale={locale}
110
+ numberOfMonths={numberOfMonths}
15
111
  className={cn('p-3', className)}
16
112
  classNames={{
17
113
  months: 'flex flex-col sm:flex-row gap-4',
18
- month: 'space-y-4',
19
- month_caption: 'flex justify-center pt-1 relative items-center',
20
- caption_label: 'text-sm font-medium',
21
- nav: 'flex items-center gap-1',
22
- button_previous: cn(
23
- 'absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
24
- 'inline-flex items-center justify-center rounded-md text-sm font-medium',
25
- 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
26
- 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
27
- 'hover:bg-accent hover:text-accent-foreground border-0'
28
- ),
29
- button_next: cn(
30
- 'absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
31
- 'inline-flex items-center justify-center rounded-md text-sm font-medium',
32
- 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
33
- 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
34
- 'hover:bg-accent hover:text-accent-foreground border-0'
35
- ),
36
- month_grid: 'w-full border-collapse space-y-1',
114
+ month: 'space-y-2',
115
+ month_caption: '',
116
+ caption_label: 'sr-only',
117
+ nav: 'sr-only',
118
+ month_grid: 'w-full border-collapse',
37
119
  weekdays: 'flex',
38
120
  weekday: 'text-muted-foreground rounded-md w-9 font-normal text-xs',
39
- weeks: 'w-full border-collapse space-y-1',
40
- week: 'flex w-full mt-2',
41
- day: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
121
+ weeks: 'w-full border-collapse',
122
+ week: 'flex w-full mt-1',
123
+ day: 'h-9 w-9 text-center text-sm p-0 relative focus-within:relative focus-within:z-20',
42
124
  day_button: cn(
43
125
  'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
44
126
  'inline-flex items-center justify-center rounded-md text-sm',
45
- 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
46
- 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
47
- 'hover:bg-accent hover:text-accent-foreground'
127
+ 'transition-colors focus:outline-none focus-visible:outline-none disabled:pointer-events-none',
128
+ // Focus indicator is a soft accent fill instead of a ring overlay — keyboard
129
+ // users get a visible cue, but mouse-click focus does not leave a stuck ring
130
+ // on top of the selected cell.
131
+ 'hover:bg-accent hover:text-accent-foreground',
132
+ 'focus-visible:bg-accent focus-visible:text-accent-foreground',
133
+ ),
134
+ // React-day-picker v9 applies `classNames.selected` / `range_*` to the day
135
+ // CELL (`<td>`) wrapper, not to the inner `<button>`. To keep the parent
136
+ // fill visible through interaction, we (a) paint the cell with the desired
137
+ // bg/text, and (b) force the inner button to render transparent — so the
138
+ // button's own hover/focus-visible bg overrides cannot cover the cell fill.
139
+ selected: cn(
140
+ '!bg-primary !text-primary-foreground rounded-md',
141
+ '[&_button]:!bg-transparent [&_button]:!text-primary-foreground',
142
+ '[&_button:hover]:!bg-transparent [&_button:hover]:!text-primary-foreground',
143
+ '[&_button:focus-visible]:!bg-transparent [&_button:focus-visible]:!text-primary-foreground',
144
+ ),
145
+ range_start: cn(
146
+ '!bg-primary !text-primary-foreground rounded-l-md !rounded-r-none',
147
+ '[&_button]:!bg-transparent [&_button]:!text-primary-foreground',
148
+ '[&_button:hover]:!bg-transparent [&_button:hover]:!text-primary-foreground',
149
+ '[&_button:focus-visible]:!bg-transparent [&_button:focus-visible]:!text-primary-foreground',
150
+ ),
151
+ range_end: cn(
152
+ '!bg-primary !text-primary-foreground rounded-r-md !rounded-l-none',
153
+ '[&_button]:!bg-transparent [&_button]:!text-primary-foreground',
154
+ '[&_button:hover]:!bg-transparent [&_button:hover]:!text-primary-foreground',
155
+ '[&_button:focus-visible]:!bg-transparent [&_button:focus-visible]:!text-primary-foreground',
156
+ ),
157
+ range_middle: cn(
158
+ '!bg-accent !text-accent-foreground !rounded-none',
159
+ '[&_button]:!bg-transparent [&_button]:!text-accent-foreground',
160
+ '[&_button:hover]:!bg-transparent [&_button:hover]:!text-accent-foreground',
161
+ '[&_button:focus-visible]:!bg-transparent [&_button:focus-visible]:!text-accent-foreground',
48
162
  ),
49
- selected:
50
- 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md',
51
- today: 'bg-accent text-accent-foreground rounded-md',
163
+ today: 'font-semibold text-primary rounded-md',
52
164
  outside:
53
- 'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
165
+ 'day-outside text-muted-foreground opacity-40 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
54
166
  disabled: 'text-muted-foreground opacity-50',
55
167
  hidden: 'invisible',
56
168
  ...classNames,
57
169
  }}
58
170
  components={{
59
- Chevron: ({ orientation }) =>
60
- orientation === 'left' ? (
61
- <ChevronLeft className="h-4 w-4" />
62
- ) : (
63
- <ChevronRight className="h-4 w-4" />
64
- ),
171
+ MonthCaption: monthCaption,
172
+ ...components,
65
173
  }}
66
174
  {...props}
67
175
  />
@@ -0,0 +1,59 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '@open-mercato/shared/lib/utils'
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectGroup,
9
+ SelectItem,
10
+ SelectLabel,
11
+ SelectSeparator,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ type SelectTriggerProps,
15
+ } from './select'
16
+
17
+ export type CompactSelectTriggerProps = Omit<SelectTriggerProps, 'size'> & {
18
+ /**
19
+ * Optional inline prefix rendered before the selected value, e.g. `View:` or
20
+ * `Sort by:`. Renders in muted style and shrinks before the value text.
21
+ */
22
+ triggerLabel?: React.ReactNode
23
+ }
24
+
25
+ export const CompactSelectTrigger = React.forwardRef<
26
+ React.ElementRef<typeof SelectTrigger>,
27
+ CompactSelectTriggerProps
28
+ >(({ triggerLabel, children, className, ...props }, ref) => (
29
+ <SelectTrigger
30
+ ref={ref}
31
+ size="xs"
32
+ className={cn(className)}
33
+ data-slot="compact-select-trigger"
34
+ {...props}
35
+ >
36
+ {triggerLabel ? (
37
+ <span
38
+ className="shrink-0 text-xs font-medium text-muted-foreground"
39
+ data-slot="compact-select-trigger-label"
40
+ >
41
+ {triggerLabel}
42
+ </span>
43
+ ) : null}
44
+ {children}
45
+ </SelectTrigger>
46
+ ))
47
+ CompactSelectTrigger.displayName = 'CompactSelectTrigger'
48
+
49
+ // Re-export the rest of the Select API so consumers can import the entire
50
+ // compact-select composition from one path.
51
+ export {
52
+ Select,
53
+ SelectContent,
54
+ SelectGroup,
55
+ SelectItem,
56
+ SelectLabel,
57
+ SelectSeparator,
58
+ SelectValue,
59
+ }