@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- package/src/primitives/time-picker.tsx +9 -11
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
5
|
+
import { X } from 'lucide-react'
|
|
6
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
7
|
+
|
|
8
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Side-sheet primitive — slides in from `right` (default), `left`,
|
|
12
|
+
* `top`, or `bottom`. Distinct from `Dialog`:
|
|
13
|
+
*
|
|
14
|
+
* `Drawer` — contextual / non-blocking-feeling. Used for detail
|
|
15
|
+
* panes, secondary forms, mobile menus. The user can
|
|
16
|
+
* glance at it while the surrounding page stays visible
|
|
17
|
+
* at a glance behind the overlay.
|
|
18
|
+
* `Dialog` — modal / focused. Used for confirmations, critical
|
|
19
|
+
* forms, and full-content workflows that should fully
|
|
20
|
+
* replace the page underneath.
|
|
21
|
+
*
|
|
22
|
+
* Built on `@radix-ui/react-dialog` (Radix Dialog under the hood —
|
|
23
|
+
* inherits role="dialog", aria-modal, focus trap, ESC to close,
|
|
24
|
+
* outside-click to dismiss).
|
|
25
|
+
*
|
|
26
|
+
* Figma source: DS Open Mercato `Drawer` page (`486:7366`) —
|
|
27
|
+
* `Drawer Header [1.1]` (`3187:2897`) covers 4 header variants
|
|
28
|
+
* (title only / title + leading icon / title + description /
|
|
29
|
+
* title + description + leading icon badge); `Drawer Footer [1.1]`
|
|
30
|
+
* (`4096:21416`) covers 6 footer variants (50/50 stretched, right-
|
|
31
|
+
* aligned compact, left checkbox + right buttons, left switch +
|
|
32
|
+
* right buttons, left step dots + right buttons, left link button +
|
|
33
|
+
* right buttons). Per the Figma examples the panel has rounded
|
|
34
|
+
* corners on the inner (viewport-facing) edges only, no border on
|
|
35
|
+
* the seam, and no chrome-level dividers between header / body /
|
|
36
|
+
* footer (separators inside the body come from content, not the
|
|
37
|
+
* Drawer).
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const [open, setOpen] = React.useState(false)
|
|
41
|
+
* <Drawer open={open} onOpenChange={setOpen} side="right">
|
|
42
|
+
* <DrawerTrigger asChild>
|
|
43
|
+
* <Button>Open drawer</Button>
|
|
44
|
+
* </DrawerTrigger>
|
|
45
|
+
* <DrawerContent>
|
|
46
|
+
* <DrawerHeader leading={<Clock />}>
|
|
47
|
+
* <DrawerTitle>Edit person</DrawerTitle>
|
|
48
|
+
* <DrawerDescription>Update the person's contact info.</DrawerDescription>
|
|
49
|
+
* </DrawerHeader>
|
|
50
|
+
* <DrawerBody>{children}</DrawerBody>
|
|
51
|
+
* <DrawerFooter layout="equal">
|
|
52
|
+
* <DrawerClose asChild>
|
|
53
|
+
* <Button variant="outline">Cancel</Button>
|
|
54
|
+
* </DrawerClose>
|
|
55
|
+
* <Button>Continue</Button>
|
|
56
|
+
* </DrawerFooter>
|
|
57
|
+
* </DrawerContent>
|
|
58
|
+
* </Drawer>
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const Drawer = DialogPrimitive.Root
|
|
63
|
+
const DrawerTrigger = DialogPrimitive.Trigger
|
|
64
|
+
const DrawerPortal = DialogPrimitive.Portal
|
|
65
|
+
const DrawerClose = DialogPrimitive.Close
|
|
66
|
+
|
|
67
|
+
const DrawerOverlay = React.forwardRef<
|
|
68
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
69
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
70
|
+
>(({ className, ...props }, ref) => (
|
|
71
|
+
<DialogPrimitive.Overlay
|
|
72
|
+
ref={ref}
|
|
73
|
+
data-slot="drawer-overlay"
|
|
74
|
+
className={cn(
|
|
75
|
+
'fixed inset-0 z-overlay bg-foreground/40 backdrop-blur-sm',
|
|
76
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
77
|
+
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
))
|
|
83
|
+
DrawerOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
84
|
+
|
|
85
|
+
const drawerContentVariants = cva(
|
|
86
|
+
// Base layout — fixed position, flex column so DrawerHeader / Body
|
|
87
|
+
// / Footer compose vertically with the body filling free space.
|
|
88
|
+
// Per Figma the panel has rounded corners on the inner (viewport-
|
|
89
|
+
// facing) edges only, no border on the seam, and a generous shadow.
|
|
90
|
+
'fixed z-popover flex flex-col gap-0 bg-background shadow-2xl outline-none ' +
|
|
91
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out ' +
|
|
92
|
+
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
|
93
|
+
{
|
|
94
|
+
variants: {
|
|
95
|
+
side: {
|
|
96
|
+
right:
|
|
97
|
+
'inset-y-0 right-0 h-full w-full max-w-md rounded-l-xl ' +
|
|
98
|
+
'data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right',
|
|
99
|
+
left:
|
|
100
|
+
'inset-y-0 left-0 h-full w-full max-w-md rounded-r-xl ' +
|
|
101
|
+
'data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left',
|
|
102
|
+
top:
|
|
103
|
+
'inset-x-0 top-0 max-h-[80vh] w-full rounded-b-xl ' +
|
|
104
|
+
'data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top',
|
|
105
|
+
bottom:
|
|
106
|
+
'inset-x-0 bottom-0 max-h-[80vh] w-full rounded-t-xl ' +
|
|
107
|
+
'data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
defaultVariants: { side: 'right' },
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
export type DrawerContentProps = React.ComponentPropsWithoutRef<
|
|
115
|
+
typeof DialogPrimitive.Content
|
|
116
|
+
> &
|
|
117
|
+
VariantProps<typeof drawerContentVariants> & {
|
|
118
|
+
/** Hide the default top-right close button. */
|
|
119
|
+
hideCloseButton?: boolean
|
|
120
|
+
/** ARIA label for the auto-rendered close button. Default `"Close"`. */
|
|
121
|
+
closeAriaLabel?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const DrawerContent = React.forwardRef<
|
|
125
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
126
|
+
DrawerContentProps
|
|
127
|
+
>(
|
|
128
|
+
(
|
|
129
|
+
{
|
|
130
|
+
className,
|
|
131
|
+
side,
|
|
132
|
+
children,
|
|
133
|
+
hideCloseButton = false,
|
|
134
|
+
closeAriaLabel = 'Close',
|
|
135
|
+
...props
|
|
136
|
+
},
|
|
137
|
+
ref,
|
|
138
|
+
) => (
|
|
139
|
+
<DrawerPortal>
|
|
140
|
+
<DrawerOverlay />
|
|
141
|
+
<DialogPrimitive.Content
|
|
142
|
+
ref={ref}
|
|
143
|
+
data-slot="drawer-content"
|
|
144
|
+
data-side={side ?? 'right'}
|
|
145
|
+
className={cn(drawerContentVariants({ side }), className)}
|
|
146
|
+
{...props}
|
|
147
|
+
>
|
|
148
|
+
{children}
|
|
149
|
+
{!hideCloseButton ? (
|
|
150
|
+
<DrawerClose
|
|
151
|
+
data-slot="drawer-close-button"
|
|
152
|
+
aria-label={closeAriaLabel}
|
|
153
|
+
className={cn(
|
|
154
|
+
'absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors',
|
|
155
|
+
'hover:bg-muted/40 hover:text-foreground',
|
|
156
|
+
'focus-visible:shadow-focus',
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<X aria-hidden="true" className="size-4" />
|
|
160
|
+
</DrawerClose>
|
|
161
|
+
) : null}
|
|
162
|
+
</DialogPrimitive.Content>
|
|
163
|
+
</DrawerPortal>
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
DrawerContent.displayName = 'DrawerContent'
|
|
167
|
+
|
|
168
|
+
export type DrawerHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
169
|
+
/**
|
|
170
|
+
* Optional leading icon — rendered inside a size-10 bordered circle
|
|
171
|
+
* badge to the left of the title block. Matches Figma `Drawer Header
|
|
172
|
+
* [1.1]` variants 2 + 4 (icon-prefixed title). Pass a `lucide-react`
|
|
173
|
+
* icon element (size-4 recommended) or any inline node.
|
|
174
|
+
*/
|
|
175
|
+
leading?: React.ReactNode
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(
|
|
179
|
+
({ className, leading, children, ...props }, ref) => (
|
|
180
|
+
<div
|
|
181
|
+
ref={ref}
|
|
182
|
+
data-slot="drawer-header"
|
|
183
|
+
className={cn(
|
|
184
|
+
// Per Figma: no chrome border below the header. Padding-right
|
|
185
|
+
// reserves room for the auto-close button at top-right.
|
|
186
|
+
'flex items-start gap-3 px-6 pt-5 pb-4 pr-14',
|
|
187
|
+
className,
|
|
188
|
+
)}
|
|
189
|
+
{...props}
|
|
190
|
+
>
|
|
191
|
+
{leading ? (
|
|
192
|
+
<span
|
|
193
|
+
data-slot="drawer-header-leading"
|
|
194
|
+
aria-hidden="true"
|
|
195
|
+
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-input bg-background text-muted-foreground"
|
|
196
|
+
>
|
|
197
|
+
{leading}
|
|
198
|
+
</span>
|
|
199
|
+
) : null}
|
|
200
|
+
<div data-slot="drawer-header-text" className="flex min-w-0 flex-1 flex-col gap-1">
|
|
201
|
+
{children}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
DrawerHeader.displayName = 'DrawerHeader'
|
|
207
|
+
|
|
208
|
+
const DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
209
|
+
({ className, ...props }, ref) => (
|
|
210
|
+
<div
|
|
211
|
+
ref={ref}
|
|
212
|
+
data-slot="drawer-body"
|
|
213
|
+
className={cn('flex-1 overflow-y-auto px-6 py-2', className)}
|
|
214
|
+
{...props}
|
|
215
|
+
/>
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
DrawerBody.displayName = 'DrawerBody'
|
|
219
|
+
|
|
220
|
+
export type DrawerFooterProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
221
|
+
/**
|
|
222
|
+
* Footer button layout per Figma `Drawer Footer [1.1]`:
|
|
223
|
+
* `default` — right-aligned compact buttons (Cancel + Continue).
|
|
224
|
+
* `equal` — children stretched to share the row equally (50/50
|
|
225
|
+
* for two children). Matches Figma variant 1.
|
|
226
|
+
* @default 'default'
|
|
227
|
+
*/
|
|
228
|
+
layout?: 'default' | 'equal'
|
|
229
|
+
/**
|
|
230
|
+
* Optional left-side slot — checkbox ("Don't show again"), switch
|
|
231
|
+
* ("Remember me"), step-indicator dots, link button, or any leading
|
|
232
|
+
* content per Figma footer variants 3–6. When provided, children
|
|
233
|
+
* stay right-aligned and the leading slot anchors left.
|
|
234
|
+
* Mutually exclusive with `layout="equal"` (Figma never combines
|
|
235
|
+
* the two — the 50/50 split is a confirmation-flow shape).
|
|
236
|
+
*/
|
|
237
|
+
leading?: React.ReactNode
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const DrawerFooter = React.forwardRef<HTMLDivElement, DrawerFooterProps>(
|
|
241
|
+
({ className, layout = 'default', leading, children, ...props }, ref) => {
|
|
242
|
+
const childArray = React.Children.toArray(children)
|
|
243
|
+
if (layout === 'equal') {
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
ref={ref}
|
|
247
|
+
data-slot="drawer-footer"
|
|
248
|
+
data-layout="equal"
|
|
249
|
+
className={cn(
|
|
250
|
+
// Per Figma: no chrome top-border separator; equal-stretch
|
|
251
|
+
// children share the row (50/50 for two buttons).
|
|
252
|
+
'flex flex-row items-center gap-3 px-6 pt-4 pb-5 [&>*]:flex-1',
|
|
253
|
+
className,
|
|
254
|
+
)}
|
|
255
|
+
{...props}
|
|
256
|
+
>
|
|
257
|
+
{childArray}
|
|
258
|
+
</div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
return (
|
|
262
|
+
<div
|
|
263
|
+
ref={ref}
|
|
264
|
+
data-slot="drawer-footer"
|
|
265
|
+
data-layout="default"
|
|
266
|
+
className={cn(
|
|
267
|
+
// Per Figma: no chrome top-border. Optional `leading` anchors
|
|
268
|
+
// left (mr-auto sibling), buttons stay right-aligned.
|
|
269
|
+
'flex flex-row items-center gap-3 px-6 pt-4 pb-5',
|
|
270
|
+
className,
|
|
271
|
+
)}
|
|
272
|
+
{...props}
|
|
273
|
+
>
|
|
274
|
+
{leading ? (
|
|
275
|
+
<div
|
|
276
|
+
data-slot="drawer-footer-leading"
|
|
277
|
+
className="mr-auto inline-flex items-center gap-2"
|
|
278
|
+
>
|
|
279
|
+
{leading}
|
|
280
|
+
</div>
|
|
281
|
+
) : null}
|
|
282
|
+
{childArray.length > 0 ? (
|
|
283
|
+
<div
|
|
284
|
+
data-slot="drawer-footer-trailing"
|
|
285
|
+
className={cn(
|
|
286
|
+
'inline-flex items-center gap-3',
|
|
287
|
+
leading ? '' : 'ml-auto',
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
{childArray}
|
|
291
|
+
</div>
|
|
292
|
+
) : null}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
DrawerFooter.displayName = 'DrawerFooter'
|
|
298
|
+
|
|
299
|
+
const DrawerTitle = React.forwardRef<
|
|
300
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
301
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
302
|
+
>(({ className, ...props }, ref) => (
|
|
303
|
+
<DialogPrimitive.Title
|
|
304
|
+
ref={ref}
|
|
305
|
+
data-slot="drawer-title"
|
|
306
|
+
className={cn('text-base font-semibold leading-tight text-foreground', className)}
|
|
307
|
+
{...props}
|
|
308
|
+
/>
|
|
309
|
+
))
|
|
310
|
+
DrawerTitle.displayName = DialogPrimitive.Title.displayName
|
|
311
|
+
|
|
312
|
+
const DrawerDescription = React.forwardRef<
|
|
313
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
314
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
315
|
+
>(({ className, ...props }, ref) => (
|
|
316
|
+
<DialogPrimitive.Description
|
|
317
|
+
ref={ref}
|
|
318
|
+
data-slot="drawer-description"
|
|
319
|
+
className={cn('text-sm text-muted-foreground', className)}
|
|
320
|
+
{...props}
|
|
321
|
+
/>
|
|
322
|
+
))
|
|
323
|
+
DrawerDescription.displayName = DialogPrimitive.Description.displayName
|
|
324
|
+
|
|
325
|
+
export {
|
|
326
|
+
Drawer,
|
|
327
|
+
DrawerTrigger,
|
|
328
|
+
DrawerPortal,
|
|
329
|
+
DrawerClose,
|
|
330
|
+
DrawerOverlay,
|
|
331
|
+
DrawerContent,
|
|
332
|
+
DrawerHeader,
|
|
333
|
+
DrawerBody,
|
|
334
|
+
DrawerFooter,
|
|
335
|
+
DrawerTitle,
|
|
336
|
+
DrawerDescription,
|
|
337
|
+
drawerContentVariants,
|
|
338
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
5
|
+
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bell-icon inbox / notification list — the panel that opens when the
|
|
11
|
+
* user clicks the bell affordance in the app shell. Distinct from
|
|
12
|
+
* `Notification` (single toast in the top-right stack) and from
|
|
13
|
+
* `ActivityFeed` (chronological audit log scoped to one entity).
|
|
14
|
+
*
|
|
15
|
+
* Compound API:
|
|
16
|
+
*
|
|
17
|
+
* <NotificationFeed> — root container card (rounded,
|
|
18
|
+
* bg-background, overflow-hidden).
|
|
19
|
+
* <NotificationFeedHeader> — top row: title + actions slot
|
|
20
|
+
* (settings cog, "Mark all as read"
|
|
21
|
+
* link, etc.). Bordered bottom.
|
|
22
|
+
* <NotificationFeedList> — `<ol>` list, `divide-y` so items
|
|
23
|
+
* auto-separate.
|
|
24
|
+
* <NotificationFeedItem> — single notification entry. Slots:
|
|
25
|
+
* `icon` — left, typically a
|
|
26
|
+
* `NotificationFeedIconBadge`,
|
|
27
|
+
* `title` — bold headline,
|
|
28
|
+
* `body` — muted description,
|
|
29
|
+
* `timestamp` — smaller muted suffix,
|
|
30
|
+
* `actions` — right slot
|
|
31
|
+
* (kebab IconButton),
|
|
32
|
+
* `children` — optional indented
|
|
33
|
+
* content (inline
|
|
34
|
+
* action buttons,
|
|
35
|
+
* file chip, reply).
|
|
36
|
+
* `unread` toggles a small dot near
|
|
37
|
+
* the title. `onClick` turns the
|
|
38
|
+
* whole row into a clickable
|
|
39
|
+
* affordance.
|
|
40
|
+
* <NotificationFeedFooter> — bottom row. Bordered top. Typical
|
|
41
|
+
* content: a full-width Archive All
|
|
42
|
+
* button OR keyboard-hint + settings
|
|
43
|
+
* link (matches the two Figma
|
|
44
|
+
* `Notifications Footer [1.1]` /
|
|
45
|
+
* block-example variants).
|
|
46
|
+
* <NotificationFeedIconBadge> — helper: `size-10 rounded-full
|
|
47
|
+
* bg-{tone}/10 text-{tone}` badge
|
|
48
|
+
* matching the Figma assembled
|
|
49
|
+
* example icon style.
|
|
50
|
+
*
|
|
51
|
+
* Figma source: DS Open Mercato `Notifications` page (`4096:21398`) —
|
|
52
|
+
* `Notifications Items [1.1]` (`4308:731`, 8 variants — 4 designs
|
|
53
|
+
* × 2 states: default + hover/selected), `Notifications Header [1.1]`
|
|
54
|
+
* (`4308:1004`), `Notifications Footer [1.1]` (`4308:5526`),
|
|
55
|
+
* `Notifications Tab Menu [1.1]` (`4349:46656`); assembled examples
|
|
56
|
+
* `166926:7047`, `166926:7088`, `166926:7114`, `166926:7138`.
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* <NotificationFeed>
|
|
60
|
+
* <NotificationFeedHeader title="Notifications">
|
|
61
|
+
* <IconButton size="sm" variant="ghost" aria-label="Settings">
|
|
62
|
+
* <Settings />
|
|
63
|
+
* </IconButton>
|
|
64
|
+
* </NotificationFeedHeader>
|
|
65
|
+
*
|
|
66
|
+
* <NotificationFeedList>
|
|
67
|
+
* <NotificationFeedItem
|
|
68
|
+
* icon={
|
|
69
|
+
* <NotificationFeedIconBadge tone="indigo">
|
|
70
|
+
* <UserPlus className="size-5" />
|
|
71
|
+
* </NotificationFeedIconBadge>
|
|
72
|
+
* }
|
|
73
|
+
* title="New Lead Generated"
|
|
74
|
+
* body="John Smith submitted web form"
|
|
75
|
+
* timestamp="10 minutes ago"
|
|
76
|
+
* unread
|
|
77
|
+
* onClick={() => router.push('/leads/123')}
|
|
78
|
+
* />
|
|
79
|
+
* </NotificationFeedList>
|
|
80
|
+
*
|
|
81
|
+
* <NotificationFeedFooter>
|
|
82
|
+
* <Button variant="outline" className="w-full">Archive all</Button>
|
|
83
|
+
* </NotificationFeedFooter>
|
|
84
|
+
* </NotificationFeed>
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
const NotificationFeed = React.forwardRef<
|
|
89
|
+
HTMLDivElement,
|
|
90
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
91
|
+
>(({ className, ...props }, ref) => (
|
|
92
|
+
<div
|
|
93
|
+
ref={ref}
|
|
94
|
+
data-slot="notification-feed"
|
|
95
|
+
className={cn(
|
|
96
|
+
'flex w-full flex-col overflow-hidden rounded-xl border border-input bg-background shadow-lg',
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
))
|
|
102
|
+
NotificationFeed.displayName = 'NotificationFeed'
|
|
103
|
+
|
|
104
|
+
export type NotificationFeedHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
105
|
+
/** Header title text. Renders as `<h3 className="text-base font-semibold">`. */
|
|
106
|
+
title?: React.ReactNode
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const NotificationFeedHeader = React.forwardRef<HTMLDivElement, NotificationFeedHeaderProps>(
|
|
110
|
+
({ className, title, children, ...props }, ref) => (
|
|
111
|
+
<div
|
|
112
|
+
ref={ref}
|
|
113
|
+
data-slot="notification-feed-header"
|
|
114
|
+
className={cn(
|
|
115
|
+
'flex items-center justify-between gap-3 border-b border-input px-4 py-3',
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
{...props}
|
|
119
|
+
>
|
|
120
|
+
{title ? (
|
|
121
|
+
<h3
|
|
122
|
+
data-slot="notification-feed-header-title"
|
|
123
|
+
className="text-base font-semibold text-foreground"
|
|
124
|
+
>
|
|
125
|
+
{title}
|
|
126
|
+
</h3>
|
|
127
|
+
) : null}
|
|
128
|
+
{children ? (
|
|
129
|
+
<div data-slot="notification-feed-header-actions" className="inline-flex items-center gap-1">
|
|
130
|
+
{children}
|
|
131
|
+
</div>
|
|
132
|
+
) : null}
|
|
133
|
+
</div>
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
NotificationFeedHeader.displayName = 'NotificationFeedHeader'
|
|
137
|
+
|
|
138
|
+
const NotificationFeedList = React.forwardRef<
|
|
139
|
+
HTMLOListElement,
|
|
140
|
+
React.HTMLAttributes<HTMLOListElement>
|
|
141
|
+
>(({ className, ...props }, ref) => (
|
|
142
|
+
<ol
|
|
143
|
+
ref={ref}
|
|
144
|
+
data-slot="notification-feed-list"
|
|
145
|
+
className={cn('flex flex-col divide-y divide-input', className)}
|
|
146
|
+
{...props}
|
|
147
|
+
/>
|
|
148
|
+
))
|
|
149
|
+
NotificationFeedList.displayName = 'NotificationFeedList'
|
|
150
|
+
|
|
151
|
+
export type NotificationFeedItemProps = Omit<
|
|
152
|
+
React.HTMLAttributes<HTMLLIElement>,
|
|
153
|
+
'title' | 'onClick'
|
|
154
|
+
> & {
|
|
155
|
+
/** Left slot — typically a `NotificationFeedIconBadge`. */
|
|
156
|
+
icon?: React.ReactNode
|
|
157
|
+
/** Bold headline. */
|
|
158
|
+
title: React.ReactNode
|
|
159
|
+
/** Optional muted body text under the title. */
|
|
160
|
+
body?: React.ReactNode
|
|
161
|
+
/** Optional smaller muted suffix under the body. */
|
|
162
|
+
timestamp?: React.ReactNode
|
|
163
|
+
/** Whether to render the unread indicator dot beside the title. */
|
|
164
|
+
unread?: boolean
|
|
165
|
+
/** Optional right-slot actions (e.g. kebab IconButton). Visible on
|
|
166
|
+
* hover / focus per Figma. Pass `IconButton variant="ghost" size="sm"`. */
|
|
167
|
+
actions?: React.ReactNode
|
|
168
|
+
/** Optional click handler. When provided the whole row becomes a
|
|
169
|
+
* clickable affordance (hover bg, focus ring). */
|
|
170
|
+
onClick?: () => void
|
|
171
|
+
/** Accessible label for the row when `onClick` is provided. Default:
|
|
172
|
+
* the `title` if it's a string, otherwise `'Open notification'`. */
|
|
173
|
+
clickAriaLabel?: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const NotificationFeedItem = React.forwardRef<HTMLLIElement, NotificationFeedItemProps>(
|
|
177
|
+
(
|
|
178
|
+
{
|
|
179
|
+
className,
|
|
180
|
+
icon,
|
|
181
|
+
title,
|
|
182
|
+
body,
|
|
183
|
+
timestamp,
|
|
184
|
+
unread = false,
|
|
185
|
+
actions,
|
|
186
|
+
onClick,
|
|
187
|
+
clickAriaLabel,
|
|
188
|
+
children,
|
|
189
|
+
...props
|
|
190
|
+
},
|
|
191
|
+
ref,
|
|
192
|
+
) => {
|
|
193
|
+
const t = useT()
|
|
194
|
+
const interactive = typeof onClick === 'function'
|
|
195
|
+
return (
|
|
196
|
+
<li
|
|
197
|
+
ref={ref}
|
|
198
|
+
data-slot="notification-feed-item"
|
|
199
|
+
data-unread={unread || undefined}
|
|
200
|
+
className={cn(
|
|
201
|
+
'group flex items-start gap-3 px-4 py-3',
|
|
202
|
+
interactive ? 'cursor-pointer hover:bg-muted/40 focus-within:bg-muted/40' : '',
|
|
203
|
+
className,
|
|
204
|
+
)}
|
|
205
|
+
onClick={onClick}
|
|
206
|
+
role={interactive ? 'button' : undefined}
|
|
207
|
+
tabIndex={interactive ? 0 : undefined}
|
|
208
|
+
aria-label={
|
|
209
|
+
interactive
|
|
210
|
+
? clickAriaLabel ??
|
|
211
|
+
(typeof title === 'string' ? title : t('ui.notificationFeed.item.openAriaLabel', 'Open notification'))
|
|
212
|
+
: undefined
|
|
213
|
+
}
|
|
214
|
+
onKeyDown={
|
|
215
|
+
interactive
|
|
216
|
+
? (event) => {
|
|
217
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
218
|
+
event.preventDefault()
|
|
219
|
+
onClick?.()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
: undefined
|
|
223
|
+
}
|
|
224
|
+
{...props}
|
|
225
|
+
>
|
|
226
|
+
{icon ? (
|
|
227
|
+
<span
|
|
228
|
+
data-slot="notification-feed-item-icon"
|
|
229
|
+
className="inline-flex shrink-0 items-center justify-center"
|
|
230
|
+
>
|
|
231
|
+
{icon}
|
|
232
|
+
</span>
|
|
233
|
+
) : null}
|
|
234
|
+
|
|
235
|
+
<div data-slot="notification-feed-item-body" className="flex min-w-0 flex-1 flex-col gap-1">
|
|
236
|
+
<div
|
|
237
|
+
data-slot="notification-feed-item-title"
|
|
238
|
+
className="flex items-center gap-2 text-sm font-semibold text-foreground"
|
|
239
|
+
>
|
|
240
|
+
<span className="truncate">{title}</span>
|
|
241
|
+
{unread ? (
|
|
242
|
+
<span
|
|
243
|
+
data-slot="notification-feed-item-unread-dot"
|
|
244
|
+
aria-hidden="true"
|
|
245
|
+
className="inline-block size-2 shrink-0 rounded-full bg-accent-indigo"
|
|
246
|
+
/>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
{body ? (
|
|
250
|
+
<div
|
|
251
|
+
data-slot="notification-feed-item-text"
|
|
252
|
+
className="text-sm leading-5 text-muted-foreground"
|
|
253
|
+
>
|
|
254
|
+
{body}
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
257
|
+
{timestamp ? (
|
|
258
|
+
<div
|
|
259
|
+
data-slot="notification-feed-item-timestamp"
|
|
260
|
+
className="text-xs leading-4 text-muted-foreground"
|
|
261
|
+
>
|
|
262
|
+
{timestamp}
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
{children ? (
|
|
266
|
+
<div data-slot="notification-feed-item-children" className="mt-2 flex flex-wrap gap-2">
|
|
267
|
+
{children}
|
|
268
|
+
</div>
|
|
269
|
+
) : null}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{actions ? (
|
|
273
|
+
<div
|
|
274
|
+
data-slot="notification-feed-item-actions"
|
|
275
|
+
className="ml-auto inline-flex shrink-0 items-start opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
|
276
|
+
onClick={(event) => event.stopPropagation()}
|
|
277
|
+
>
|
|
278
|
+
{actions}
|
|
279
|
+
</div>
|
|
280
|
+
) : null}
|
|
281
|
+
</li>
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
NotificationFeedItem.displayName = 'NotificationFeedItem'
|
|
286
|
+
|
|
287
|
+
const NotificationFeedFooter = React.forwardRef<
|
|
288
|
+
HTMLDivElement,
|
|
289
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
290
|
+
>(({ className, ...props }, ref) => (
|
|
291
|
+
<div
|
|
292
|
+
ref={ref}
|
|
293
|
+
data-slot="notification-feed-footer"
|
|
294
|
+
className={cn(
|
|
295
|
+
'flex items-center justify-between gap-3 border-t border-input px-4 py-3',
|
|
296
|
+
className,
|
|
297
|
+
)}
|
|
298
|
+
{...props}
|
|
299
|
+
/>
|
|
300
|
+
))
|
|
301
|
+
NotificationFeedFooter.displayName = 'NotificationFeedFooter'
|
|
302
|
+
|
|
303
|
+
const ICON_TONE_MAP = {
|
|
304
|
+
// Tints + matching icon color per Figma `Notifications Items [1.1]`
|
|
305
|
+
// assembled examples. Surface is a soft `tone/10` background; icon
|
|
306
|
+
// carries the semantic color directly.
|
|
307
|
+
indigo: 'bg-accent-indigo/10 text-accent-indigo',
|
|
308
|
+
success: 'bg-status-success-icon/10 text-status-success-icon',
|
|
309
|
+
warning: 'bg-status-warning-icon/10 text-status-warning-icon',
|
|
310
|
+
error: 'bg-status-error-icon/10 text-status-error-icon',
|
|
311
|
+
info: 'bg-status-info-icon/10 text-status-info-icon',
|
|
312
|
+
brand: 'bg-brand-violet/10 text-brand-violet',
|
|
313
|
+
neutral: 'bg-muted text-muted-foreground',
|
|
314
|
+
} as const
|
|
315
|
+
|
|
316
|
+
const iconBadgeVariants = cva(
|
|
317
|
+
'inline-flex shrink-0 items-center justify-center rounded-full',
|
|
318
|
+
{
|
|
319
|
+
variants: {
|
|
320
|
+
tone: ICON_TONE_MAP,
|
|
321
|
+
size: {
|
|
322
|
+
sm: 'size-8',
|
|
323
|
+
default: 'size-10',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
defaultVariants: { tone: 'indigo', size: 'default' },
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
export type NotificationFeedIconBadgeProps = React.HTMLAttributes<HTMLSpanElement> &
|
|
331
|
+
VariantProps<typeof iconBadgeVariants>
|
|
332
|
+
|
|
333
|
+
const NotificationFeedIconBadge = React.forwardRef<
|
|
334
|
+
HTMLSpanElement,
|
|
335
|
+
NotificationFeedIconBadgeProps
|
|
336
|
+
>(({ className, tone, size, children, ...props }, ref) => (
|
|
337
|
+
<span
|
|
338
|
+
ref={ref}
|
|
339
|
+
data-slot="notification-feed-icon-badge"
|
|
340
|
+
data-tone={tone ?? 'indigo'}
|
|
341
|
+
className={cn(iconBadgeVariants({ tone, size }), className)}
|
|
342
|
+
{...props}
|
|
343
|
+
>
|
|
344
|
+
{children}
|
|
345
|
+
</span>
|
|
346
|
+
))
|
|
347
|
+
NotificationFeedIconBadge.displayName = 'NotificationFeedIconBadge'
|
|
348
|
+
|
|
349
|
+
export type NotificationFeedIconBadgeTone = keyof typeof ICON_TONE_MAP
|
|
350
|
+
|
|
351
|
+
export {
|
|
352
|
+
NotificationFeed,
|
|
353
|
+
NotificationFeedHeader,
|
|
354
|
+
NotificationFeedList,
|
|
355
|
+
NotificationFeedItem,
|
|
356
|
+
NotificationFeedFooter,
|
|
357
|
+
NotificationFeedIconBadge,
|
|
358
|
+
}
|