@open-mercato/ui 0.6.2-develop.3372.1.de9a169973 → 0.6.2-develop.3380.1.bb14509041

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/ai/AiIcon.js +1 -1
  4. package/dist/ai/AiIcon.js.map +2 -2
  5. package/dist/backend/AppShell.js +223 -153
  6. package/dist/backend/AppShell.js.map +2 -2
  7. package/dist/backend/ProfileDropdown.js +101 -79
  8. package/dist/backend/ProfileDropdown.js.map +2 -2
  9. package/dist/backend/notifications/NotificationCountBadge.js +1 -1
  10. package/dist/backend/notifications/NotificationCountBadge.js.map +2 -2
  11. package/dist/backend/notifications/NotificationItem.js +67 -50
  12. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  13. package/dist/backend/notifications/NotificationPanel.js +108 -85
  14. package/dist/backend/notifications/NotificationPanel.js.map +2 -2
  15. package/dist/primitives/breadcrumb.js +161 -0
  16. package/dist/primitives/breadcrumb.js.map +7 -0
  17. package/dist/primitives/sheet.js +114 -0
  18. package/dist/primitives/sheet.js.map +7 -0
  19. package/package.json +3 -3
  20. package/src/ai/AiIcon.tsx +1 -1
  21. package/src/backend/AppShell.tsx +151 -49
  22. package/src/backend/ProfileDropdown.tsx +115 -88
  23. package/src/backend/__tests__/AppShell.test.tsx +8 -0
  24. package/src/backend/notifications/NotificationCountBadge.tsx +1 -1
  25. package/src/backend/notifications/NotificationItem.tsx +97 -104
  26. package/src/backend/notifications/NotificationPanel.tsx +126 -102
  27. package/src/primitives/__tests__/breadcrumb.test.tsx +298 -0
  28. package/src/primitives/__tests__/sheet.test.tsx +152 -0
  29. package/src/primitives/breadcrumb.tsx +185 -0
  30. package/src/primitives/sheet.tsx +165 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import {
8
+ Sheet,
9
+ SheetClose,
10
+ SheetContent,
11
+ SheetDescription,
12
+ SheetFooter,
13
+ SheetHeader,
14
+ SheetTitle,
15
+ SheetTrigger,
16
+ } from '../sheet'
17
+
18
+ function renderControlled(
19
+ initial = false,
20
+ contentProps: Partial<React.ComponentProps<typeof SheetContent>> = {},
21
+ ) {
22
+ function Harness() {
23
+ const [open, setOpen] = React.useState(initial)
24
+ return (
25
+ <Sheet open={open} onOpenChange={setOpen}>
26
+ <SheetTrigger asChild>
27
+ <button type="button">Open</button>
28
+ </SheetTrigger>
29
+ <SheetContent {...contentProps}>
30
+ <SheetHeader>
31
+ <SheetTitle>Notifications</SheetTitle>
32
+ <SheetDescription>Recent activity from your team.</SheetDescription>
33
+ </SheetHeader>
34
+ <div data-testid="sheet-body">body</div>
35
+ <SheetFooter>
36
+ <SheetClose asChild>
37
+ <button type="button" data-testid="explicit-close">
38
+ Close
39
+ </button>
40
+ </SheetClose>
41
+ </SheetFooter>
42
+ </SheetContent>
43
+ </Sheet>
44
+ )
45
+ }
46
+ return render(<Harness />)
47
+ }
48
+
49
+ describe('Sheet primitive', () => {
50
+ it('does not render content when closed', () => {
51
+ renderControlled(false)
52
+ expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
53
+ expect(screen.queryByTestId('sheet-body')).not.toBeInTheDocument()
54
+ })
55
+
56
+ it('opens when the trigger is clicked', () => {
57
+ renderControlled(false)
58
+ fireEvent.click(screen.getByText('Open'))
59
+ expect(screen.getByText('Notifications')).toBeInTheDocument()
60
+ expect(screen.getByTestId('sheet-body')).toBeInTheDocument()
61
+ })
62
+
63
+ it('renders content immediately when controlled open=true', () => {
64
+ renderControlled(true)
65
+ expect(screen.getByText('Notifications')).toBeInTheDocument()
66
+ })
67
+
68
+ it('renders a built-in close X with the default closeLabel', () => {
69
+ renderControlled(true)
70
+ expect(screen.getByLabelText('Close')).toBeInTheDocument()
71
+ })
72
+
73
+ it('respects a custom closeLabel', () => {
74
+ renderControlled(true, { closeLabel: 'Close notifications' })
75
+ expect(screen.getByLabelText('Close notifications')).toBeInTheDocument()
76
+ expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
77
+ })
78
+
79
+ it('omits the built-in close when hideClose is set', () => {
80
+ renderControlled(true, { hideClose: true, closeLabel: 'Close notifications' })
81
+ expect(screen.queryByLabelText('Close notifications')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('closes when the built-in close X is clicked', () => {
85
+ renderControlled(true)
86
+ fireEvent.click(screen.getByLabelText('Close'))
87
+ expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
88
+ })
89
+
90
+ it('closes when an explicit SheetClose child is clicked', () => {
91
+ renderControlled(true)
92
+ fireEvent.click(screen.getByTestId('explicit-close'))
93
+ expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
94
+ })
95
+
96
+ it('renders content with dialog role', () => {
97
+ renderControlled(true)
98
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
99
+ })
100
+
101
+ it('renders side="right" by default with slide-in-from-right animation class', () => {
102
+ renderControlled(true)
103
+ const dialog = screen.getByRole('dialog')
104
+ expect(dialog.className).toContain('right-0')
105
+ expect(dialog.className).toContain('data-[state=open]:slide-in-from-right')
106
+ })
107
+
108
+ it('renders side="left" with corresponding slide direction', () => {
109
+ renderControlled(true, { side: 'left' })
110
+ const dialog = screen.getByRole('dialog')
111
+ expect(dialog.className).toContain('left-0')
112
+ expect(dialog.className).toContain('data-[state=open]:slide-in-from-left')
113
+ })
114
+
115
+ it('side="left" uses separate top/bottom utilities (not invalid combined inset-y two-value) so the panel honors --topbar-height', () => {
116
+ // Regression: a previous implementation used `inset-y-[var(--topbar-height,0px)_0]`
117
+ // which compiles to `top: var(--topbar-height,0px) 0; bottom: var(--topbar-height,0px) 0;`
118
+ // — both rules invalid (top/bottom accept ONE length) and silently dropped by the
119
+ // browser. Assert the resolved class string carries two separate inset utilities.
120
+ renderControlled(true, { side: 'left' })
121
+ const dialog = screen.getByRole('dialog')
122
+ expect(dialog.className).toContain('top-[var(--topbar-height,0px)]')
123
+ expect(dialog.className).toContain('bottom-0')
124
+ expect(dialog.className).not.toMatch(/inset-y-\[[^\]]+_[^\]]+\]/)
125
+ })
126
+
127
+ it('renders side="top" with corresponding slide direction', () => {
128
+ renderControlled(true, { side: 'top' })
129
+ const dialog = screen.getByRole('dialog')
130
+ expect(dialog.className).toContain('top-0')
131
+ expect(dialog.className).toContain('data-[state=open]:slide-in-from-top')
132
+ })
133
+
134
+ it('renders side="bottom" with corresponding slide direction', () => {
135
+ renderControlled(true, { side: 'bottom' })
136
+ const dialog = screen.getByRole('dialog')
137
+ expect(dialog.className).toContain('bottom-0')
138
+ expect(dialog.className).toContain('data-[state=open]:slide-in-from-bottom')
139
+ })
140
+
141
+ it('content references --topbar-height CSS variable so it sits below the AppShell sticky topbar', () => {
142
+ renderControlled(true)
143
+ const dialog = screen.getByRole('dialog')
144
+ expect(dialog.className).toContain('top-[var(--topbar-height,0px)]')
145
+ })
146
+
147
+ it('merges custom className onto the content node', () => {
148
+ renderControlled(true, { className: 'custom-test-class' })
149
+ const dialog = screen.getByRole('dialog')
150
+ expect(dialog.className).toContain('custom-test-class')
151
+ })
152
+ })
@@ -0,0 +1,185 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Slot } from '@radix-ui/react-slot'
5
+ import { ChevronRight, MoreHorizontal } from 'lucide-react'
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+
8
+ export type BreadcrumbDivider = 'slash' | 'arrow' | 'dot'
9
+
10
+ type BreadcrumbContextValue = {
11
+ divider: BreadcrumbDivider
12
+ }
13
+
14
+ const BreadcrumbContext = React.createContext<BreadcrumbContextValue>({
15
+ divider: 'slash',
16
+ })
17
+
18
+ export type BreadcrumbProps = React.ComponentProps<'nav'> & {
19
+ divider?: BreadcrumbDivider
20
+ }
21
+
22
+ export function Breadcrumb({
23
+ divider = 'slash',
24
+ className,
25
+ children,
26
+ ...props
27
+ }: BreadcrumbProps) {
28
+ const contextValue = React.useMemo<BreadcrumbContextValue>(
29
+ () => ({ divider }),
30
+ [divider],
31
+ )
32
+ return (
33
+ <BreadcrumbContext.Provider value={contextValue}>
34
+ <nav
35
+ aria-label="Breadcrumb"
36
+ data-slot="breadcrumb"
37
+ data-divider={divider}
38
+ className={cn('min-w-0', className)}
39
+ {...props}
40
+ >
41
+ {children}
42
+ </nav>
43
+ </BreadcrumbContext.Provider>
44
+ )
45
+ }
46
+
47
+ export type BreadcrumbListProps = React.ComponentProps<'ol'>
48
+
49
+ export function BreadcrumbList({ className, children, ...props }: BreadcrumbListProps) {
50
+ if (React.Children.count(children) === 0) return null
51
+ return (
52
+ <ol
53
+ data-slot="breadcrumb-list"
54
+ className={cn(
55
+ 'flex items-center gap-1.5 text-sm font-medium leading-5 tracking-tight min-w-0',
56
+ className,
57
+ )}
58
+ {...props}
59
+ >
60
+ {children}
61
+ </ol>
62
+ )
63
+ }
64
+
65
+ export type BreadcrumbItemProps = React.ComponentProps<'li'>
66
+
67
+ export function BreadcrumbItem({ className, ...props }: BreadcrumbItemProps) {
68
+ return (
69
+ <li
70
+ data-slot="breadcrumb-item"
71
+ className={cn('inline-flex items-center gap-1.5 min-w-0', className)}
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+
77
+ export type BreadcrumbLinkProps = React.ComponentProps<'a'> & {
78
+ asChild?: boolean
79
+ }
80
+
81
+ export function BreadcrumbLink({
82
+ asChild = false,
83
+ className,
84
+ children,
85
+ ...props
86
+ }: BreadcrumbLinkProps) {
87
+ const Comp = asChild ? Slot : 'a'
88
+ return (
89
+ <Comp
90
+ data-slot="breadcrumb-link"
91
+ className={cn(
92
+ "inline-flex items-center gap-1.5 max-w-[40vw] md:max-w-[28vw] truncate text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:text-foreground rounded-sm focus-visible:shadow-focus [&_svg:not([class*='size-'])]:size-5 [&_svg]:shrink-0",
93
+ className,
94
+ )}
95
+ {...props}
96
+ >
97
+ {children}
98
+ </Comp>
99
+ )
100
+ }
101
+
102
+ export type BreadcrumbPageProps = React.ComponentProps<'span'>
103
+
104
+ export function BreadcrumbPage({ className, ...props }: BreadcrumbPageProps) {
105
+ return (
106
+ <span
107
+ aria-current="page"
108
+ data-slot="breadcrumb-page"
109
+ className={cn(
110
+ "inline-block text-foreground truncate min-w-0 max-w-[45vw] md:max-w-[60vw] [&_svg:not([class*='size-'])]:size-5 [&_svg]:shrink-0 [&_svg]:inline-block [&_svg]:align-middle",
111
+ className,
112
+ )}
113
+ {...props}
114
+ />
115
+ )
116
+ }
117
+
118
+ export type BreadcrumbStaticProps = React.ComponentProps<'span'>
119
+
120
+ export function BreadcrumbStatic({ className, ...props }: BreadcrumbStaticProps) {
121
+ return (
122
+ <span
123
+ data-slot="breadcrumb-static"
124
+ className={cn(
125
+ "inline-flex items-center gap-1.5 max-w-[40vw] md:max-w-[28vw] truncate text-muted-foreground select-none [&_svg:not([class*='size-'])]:size-5 [&_svg]:shrink-0",
126
+ className,
127
+ )}
128
+ {...props}
129
+ />
130
+ )
131
+ }
132
+
133
+ export type BreadcrumbSeparatorProps = React.ComponentProps<'li'> & {
134
+ divider?: BreadcrumbDivider
135
+ }
136
+
137
+ export function BreadcrumbSeparator({
138
+ divider: dividerProp,
139
+ className,
140
+ children,
141
+ ...props
142
+ }: BreadcrumbSeparatorProps) {
143
+ const { divider: contextDivider } = React.useContext(BreadcrumbContext)
144
+ const divider = dividerProp ?? contextDivider
145
+ return (
146
+ <li
147
+ role="presentation"
148
+ aria-hidden="true"
149
+ data-slot="breadcrumb-separator"
150
+ data-divider={divider}
151
+ className={cn(
152
+ "inline-flex items-center text-text-disabled select-none [&_svg:not([class*='size-'])]:size-5",
153
+ className,
154
+ )}
155
+ {...props}
156
+ >
157
+ {children ?? <DefaultSeparator divider={divider} />}
158
+ </li>
159
+ )
160
+ }
161
+
162
+ function DefaultSeparator({ divider }: { divider: BreadcrumbDivider }) {
163
+ if (divider === 'arrow') return <ChevronRight aria-hidden="true" />
164
+ if (divider === 'dot') return <span aria-hidden="true">·</span>
165
+ return <span aria-hidden="true">/</span>
166
+ }
167
+
168
+ export type BreadcrumbEllipsisProps = React.ComponentProps<'span'>
169
+
170
+ export function BreadcrumbEllipsis({ className, children, ...props }: BreadcrumbEllipsisProps) {
171
+ return (
172
+ <span
173
+ role="presentation"
174
+ data-slot="breadcrumb-ellipsis"
175
+ className={cn(
176
+ 'inline-flex size-5 items-center justify-center text-muted-foreground',
177
+ className,
178
+ )}
179
+ {...props}
180
+ >
181
+ {children ?? <MoreHorizontal aria-hidden="true" className="size-4" />}
182
+ <span className="sr-only">More</span>
183
+ </span>
184
+ )
185
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
5
+ import { cva, type VariantProps } from 'class-variance-authority'
6
+ import { X } from 'lucide-react'
7
+ import { cn } from '@open-mercato/shared/lib/utils'
8
+
9
+ const Sheet = DialogPrimitive.Root
10
+
11
+ const SheetTrigger = DialogPrimitive.Trigger
12
+
13
+ const SheetClose = DialogPrimitive.Close
14
+
15
+ const SheetPortal = DialogPrimitive.Portal
16
+
17
+ const SheetOverlay = React.forwardRef<
18
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
+ >(({ className, ...props }, ref) => (
21
+ <DialogPrimitive.Overlay
22
+ className={cn(
23
+ 'fixed inset-x-0 bottom-0 top-[var(--topbar-height,0px)] z-overlay bg-black/40 backdrop-blur-sm',
24
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
25
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
26
+ className,
27
+ )}
28
+ {...props}
29
+ ref={ref}
30
+ />
31
+ ))
32
+ SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
33
+
34
+ const sheetVariants = cva(
35
+ cn(
36
+ 'fixed z-modal flex flex-col gap-4 bg-background shadow-lg transition ease-in-out',
37
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
38
+ 'data-[state=closed]:duration-200 data-[state=open]:duration-300',
39
+ ),
40
+ {
41
+ variants: {
42
+ side: {
43
+ top:
44
+ 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
45
+ bottom:
46
+ 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
47
+ left:
48
+ 'top-[var(--topbar-height,0px)] bottom-0 left-0 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-md',
49
+ right:
50
+ 'top-[var(--topbar-height,0px)] bottom-0 right-0 w-full sm:max-w-md border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
51
+ },
52
+ },
53
+ defaultVariants: {
54
+ side: 'right',
55
+ },
56
+ }
57
+ )
58
+
59
+ export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
60
+ VariantProps<typeof sheetVariants> & {
61
+ /** Hide the built-in close (X) button — useful when the consumer renders its own close affordance. */
62
+ hideClose?: boolean
63
+ /** Override the aria-label for the built-in close button. */
64
+ closeLabel?: string
65
+ }
66
+
67
+ /**
68
+ * SheetContent — side-anchored Radix Dialog wrapper.
69
+ *
70
+ * Topbar integration: the `left` and `right` variants anchor at
71
+ * `top: var(--topbar-height, 0px)` so a sticky app topbar stays visible above
72
+ * the panel. Consumers that render inside `AppShell` get the correct offset
73
+ * because the shell sets `--topbar-height` on the outer container. Surfaces
74
+ * outside the shell (portal modals, marketing pages, standalone embeds) keep
75
+ * the default `0px` fallback and align to the viewport top — that is the
76
+ * correct behavior when there is no topbar to clear.
77
+ *
78
+ * To opt into the offset from your own layout, set the CSS variable on any
79
+ * ancestor of `<SheetContent>`:
80
+ *
81
+ * ```css
82
+ * .my-shell { --topbar-height: 60px; }
83
+ * ```
84
+ */
85
+
86
+ const SheetContent = React.forwardRef<
87
+ React.ElementRef<typeof DialogPrimitive.Content>,
88
+ SheetContentProps
89
+ >(({ side = 'right', className, children, hideClose = false, closeLabel = 'Close', ...props }, ref) => (
90
+ <SheetPortal>
91
+ <SheetOverlay />
92
+ <DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
93
+ {children}
94
+ {!hideClose ? (
95
+ <DialogPrimitive.Close
96
+ className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:shadow-focus"
97
+ aria-label={closeLabel}
98
+ >
99
+ <X className="size-4" aria-hidden="true" />
100
+ </DialogPrimitive.Close>
101
+ ) : null}
102
+ </DialogPrimitive.Content>
103
+ </SheetPortal>
104
+ ))
105
+ SheetContent.displayName = DialogPrimitive.Content.displayName
106
+
107
+ const SheetHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
108
+ ({ className, ...props }, ref) => (
109
+ <div
110
+ ref={ref}
111
+ className={cn('flex flex-col gap-1.5 border-b px-4 py-3 pr-12 text-left', className)}
112
+ {...props}
113
+ />
114
+ ),
115
+ )
116
+ SheetHeader.displayName = 'SheetHeader'
117
+
118
+ const SheetFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
119
+ ({ className, ...props }, ref) => (
120
+ <div
121
+ ref={ref}
122
+ className={cn('flex flex-col-reverse gap-2 border-t px-4 py-3 sm:flex-row sm:justify-end', className)}
123
+ {...props}
124
+ />
125
+ ),
126
+ )
127
+ SheetFooter.displayName = 'SheetFooter'
128
+
129
+ const SheetTitle = React.forwardRef<
130
+ React.ElementRef<typeof DialogPrimitive.Title>,
131
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
132
+ >(({ className, ...props }, ref) => (
133
+ <DialogPrimitive.Title
134
+ ref={ref}
135
+ className={cn('text-base font-semibold text-foreground', className)}
136
+ {...props}
137
+ />
138
+ ))
139
+ SheetTitle.displayName = DialogPrimitive.Title.displayName
140
+
141
+ const SheetDescription = React.forwardRef<
142
+ React.ElementRef<typeof DialogPrimitive.Description>,
143
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
144
+ >(({ className, ...props }, ref) => (
145
+ <DialogPrimitive.Description
146
+ ref={ref}
147
+ className={cn('text-sm text-muted-foreground', className)}
148
+ {...props}
149
+ />
150
+ ))
151
+ SheetDescription.displayName = DialogPrimitive.Description.displayName
152
+
153
+ export {
154
+ Sheet,
155
+ SheetClose,
156
+ SheetContent,
157
+ SheetDescription,
158
+ SheetFooter,
159
+ SheetHeader,
160
+ SheetOverlay,
161
+ SheetPortal,
162
+ SheetTitle,
163
+ SheetTrigger,
164
+ sheetVariants,
165
+ }