@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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. 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
+ }