@open-mercato/ui 0.5.1-develop.2663.2c29774b5b → 0.5.1-develop.2681.c559bb2bc3
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 +2 -2
- package/dist/backend/CrudForm.js +187 -39
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/Page.js +12 -4
- package/dist/backend/Page.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/CollapsibleGroup.js +88 -0
- package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
- package/dist/backend/crud/useGroupCollapse.js +24 -0
- package/dist/backend/crud/useGroupCollapse.js.map +7 -0
- package/dist/backend/crud/useGroupOrder.js +61 -0
- package/dist/backend/crud/useGroupOrder.js.map +7 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
- package/dist/backend/crud/useZoneCollapse.js +24 -0
- package/dist/backend/crud/useZoneCollapse.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +77 -33
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +82 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +16 -2
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/primitives/avatar.js +59 -0
- package/dist/primitives/avatar.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +230 -21
- package/src/backend/Page.tsx +20 -4
- package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
- package/src/backend/__tests__/NotesSection.test.tsx +63 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
- package/src/backend/crud/CollapsibleGroup.tsx +111 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
- package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
- package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
- package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
- package/src/backend/crud/useGroupCollapse.ts +22 -0
- package/src/backend/crud/useGroupOrder.ts +74 -0
- package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
- package/src/backend/crud/useZoneCollapse.ts +22 -0
- package/src/backend/detail/AttachmentsSection.tsx +81 -38
- package/src/backend/detail/NotesSection.tsx +99 -6
- package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
- package/src/primitives/__tests__/avatar.test.tsx +64 -0
- package/src/primitives/avatar.tsx +75 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
|
+
import { CollapsibleZoneLayout } from '../crud/CollapsibleZoneLayout'
|
|
7
|
+
|
|
8
|
+
let currentWidth = 1400
|
|
9
|
+
let resizeObserverTarget: Element | null = null
|
|
10
|
+
let resizeObserverInstance: ResizeObserverMock | null = null
|
|
11
|
+
let desktopViewport = true
|
|
12
|
+
const mediaQueryListeners = new Set<() => void>()
|
|
13
|
+
|
|
14
|
+
function createResizeEntry(target: Element, width: number): ResizeObserverEntry {
|
|
15
|
+
return {
|
|
16
|
+
target,
|
|
17
|
+
contentRect: {
|
|
18
|
+
width,
|
|
19
|
+
height: 0,
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
top: 0,
|
|
23
|
+
right: width,
|
|
24
|
+
bottom: 0,
|
|
25
|
+
left: 0,
|
|
26
|
+
toJSON: () => ({}),
|
|
27
|
+
} as DOMRectReadOnly,
|
|
28
|
+
} as ResizeObserverEntry
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class ResizeObserverMock {
|
|
32
|
+
readonly callback: ResizeObserverCallback
|
|
33
|
+
|
|
34
|
+
constructor(callback: ResizeObserverCallback) {
|
|
35
|
+
this.callback = callback
|
|
36
|
+
resizeObserverInstance = this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
observe(target: Element) {
|
|
40
|
+
resizeObserverTarget = target
|
|
41
|
+
this.callback([createResizeEntry(target, currentWidth)], this as unknown as ResizeObserver)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
unobserve() {}
|
|
45
|
+
|
|
46
|
+
disconnect() {
|
|
47
|
+
resizeObserverTarget = null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setContainerWidth(width: number) {
|
|
52
|
+
currentWidth = width
|
|
53
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
54
|
+
configurable: true,
|
|
55
|
+
writable: true,
|
|
56
|
+
value: width,
|
|
57
|
+
})
|
|
58
|
+
if (!resizeObserverTarget || !resizeObserverInstance) return
|
|
59
|
+
resizeObserverInstance.callback(
|
|
60
|
+
[createResizeEntry(resizeObserverTarget, width)],
|
|
61
|
+
resizeObserverInstance as unknown as ResizeObserver,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('CollapsibleZoneLayout', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
currentWidth = 1400
|
|
68
|
+
desktopViewport = true
|
|
69
|
+
resizeObserverTarget = null
|
|
70
|
+
resizeObserverInstance = null
|
|
71
|
+
mediaQueryListeners.clear()
|
|
72
|
+
localStorage.clear()
|
|
73
|
+
|
|
74
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
75
|
+
configurable: true,
|
|
76
|
+
writable: true,
|
|
77
|
+
value: currentWidth,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
81
|
+
configurable: true,
|
|
82
|
+
writable: true,
|
|
83
|
+
value: jest.fn().mockImplementation(() => ({
|
|
84
|
+
matches: desktopViewport,
|
|
85
|
+
media: '(min-width: 1024px)',
|
|
86
|
+
onchange: null,
|
|
87
|
+
addEventListener: (_event: string, listener: () => void) => {
|
|
88
|
+
mediaQueryListeners.add(listener)
|
|
89
|
+
},
|
|
90
|
+
removeEventListener: (_event: string, listener: () => void) => {
|
|
91
|
+
mediaQueryListeners.delete(listener)
|
|
92
|
+
},
|
|
93
|
+
addListener: (listener: () => void) => {
|
|
94
|
+
mediaQueryListeners.add(listener)
|
|
95
|
+
},
|
|
96
|
+
removeListener: (listener: () => void) => {
|
|
97
|
+
mediaQueryListeners.delete(listener)
|
|
98
|
+
},
|
|
99
|
+
dispatchEvent: () => true,
|
|
100
|
+
})),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
;(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserverMock }).ResizeObserver = ResizeObserverMock
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('auto-collapses zone1 before the two-column layout becomes too narrow', async () => {
|
|
107
|
+
const { container } = renderWithProviders(
|
|
108
|
+
<CollapsibleZoneLayout
|
|
109
|
+
zone1={<div>Zone 1</div>}
|
|
110
|
+
zone2={<div>Zone 2</div>}
|
|
111
|
+
entityName="Brightside Solar"
|
|
112
|
+
pageType="company-v2"
|
|
113
|
+
/>,
|
|
114
|
+
{ dict: {} },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const layout = container.firstElementChild as HTMLElement
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'side-by-side')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
setContainerWidth(1180)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(screen.queryByText('Zone 1')).not.toBeInTheDocument()
|
|
132
|
+
expect(screen.getByRole('button', { name: 'Expand form panel' })).toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('stacks zone1 above zone2 when the user expands it in constrained space', async () => {
|
|
136
|
+
currentWidth = 1180
|
|
137
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
138
|
+
configurable: true,
|
|
139
|
+
writable: true,
|
|
140
|
+
value: currentWidth,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const { container } = renderWithProviders(
|
|
144
|
+
<CollapsibleZoneLayout
|
|
145
|
+
zone1={<div>Zone 1</div>}
|
|
146
|
+
zone2={<div>Zone 2</div>}
|
|
147
|
+
entityName="Brightside Solar"
|
|
148
|
+
pageType="person-v2"
|
|
149
|
+
/>,
|
|
150
|
+
{ dict: {} },
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const layout = container.firstElementChild as HTMLElement
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand form panel' }))
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'stacked')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const zone1 = screen.getByText('Zone 1')
|
|
166
|
+
const zone2 = screen.getByText('Zone 2')
|
|
167
|
+
|
|
168
|
+
expect(zone1.compareDocumentPosition(zone2) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
|
169
|
+
expect(screen.getByRole('button', { name: 'Collapse form panel' })).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -64,8 +64,8 @@ describe('CrudForm validation state', () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
await waitFor(() => {
|
|
67
|
-
expect(nameField?.querySelector('.text-xs.text-
|
|
68
|
-
expect(gatewayField?.querySelector('.text-xs.text-
|
|
67
|
+
expect(nameField?.querySelector('.text-xs.text-status-error-text')).toHaveTextContent('This field is required')
|
|
68
|
+
expect(gatewayField?.querySelector('.text-xs.text-status-error-text')).toHaveTextContent('This field is required')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
await act(async () => {
|
|
@@ -73,8 +73,8 @@ describe('CrudForm validation state', () => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
await waitFor(() => {
|
|
76
|
-
expect(nameField?.querySelector('.text-xs.text-
|
|
77
|
-
expect(gatewayField?.querySelector('.text-xs.text-
|
|
76
|
+
expect(nameField?.querySelector('.text-xs.text-status-error-text')).toBeNull()
|
|
77
|
+
expect(gatewayField?.querySelector('.text-xs.text-status-error-text')).toHaveTextContent('This field is required')
|
|
78
78
|
})
|
|
79
79
|
expect(container.textContent).toContain('Gateway provider')
|
|
80
80
|
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
|
+
import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
|
|
7
|
+
|
|
8
|
+
describe('NotesSection', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
11
|
+
configurable: true,
|
|
12
|
+
writable: true,
|
|
13
|
+
value: jest.fn(),
|
|
14
|
+
})
|
|
15
|
+
Object.defineProperty(window, 'requestAnimationFrame', {
|
|
16
|
+
configurable: true,
|
|
17
|
+
writable: true,
|
|
18
|
+
value: (callback: FrameRequestCallback) => {
|
|
19
|
+
callback(0)
|
|
20
|
+
return 0
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps an add-note action visible after notes already exist', async () => {
|
|
26
|
+
const dataAdapter: NotesDataAdapter = {
|
|
27
|
+
list: jest.fn(async () => [
|
|
28
|
+
{
|
|
29
|
+
id: 'note-1',
|
|
30
|
+
body: 'Existing note',
|
|
31
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
32
|
+
authorName: 'Ada Lovelace',
|
|
33
|
+
},
|
|
34
|
+
]),
|
|
35
|
+
create: jest.fn(async () => ({ id: 'note-2' })),
|
|
36
|
+
update: jest.fn(async () => undefined),
|
|
37
|
+
delete: jest.fn(async () => undefined),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { container } = renderWithProviders(
|
|
41
|
+
<NotesSection
|
|
42
|
+
entityId="person-1"
|
|
43
|
+
emptyLabel="—"
|
|
44
|
+
viewerUserId="user-1"
|
|
45
|
+
viewerName="Ada Lovelace"
|
|
46
|
+
addActionLabel="Add note"
|
|
47
|
+
emptyState={{
|
|
48
|
+
title: 'No notes yet',
|
|
49
|
+
actionLabel: 'Add note',
|
|
50
|
+
}}
|
|
51
|
+
dataAdapter={dataAdapter}
|
|
52
|
+
disableMarkdown
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
await screen.findByText('Existing note')
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(container.querySelector('textarea')).not.toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -53,6 +53,11 @@ export function ConfirmDialog({
|
|
|
53
53
|
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
54
54
|
const cancelButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
55
55
|
const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
56
|
+
// Unique IDs so multiple ConfirmDialog instances on the same page don't
|
|
57
|
+
// collide and make `aria-labelledby` resolve to the wrong dialog's title.
|
|
58
|
+
const reactId = React.useId();
|
|
59
|
+
const titleId = `confirm-dialog-title-${reactId}`;
|
|
60
|
+
const descriptionId = `confirm-dialog-description-${reactId}`;
|
|
56
61
|
|
|
57
62
|
// Determine if we're in controlled mode (open prop provided) or declarative mode (trigger provided)
|
|
58
63
|
const isControlled = controlledOpen !== undefined;
|
|
@@ -178,8 +183,8 @@ export function ConfirmDialog({
|
|
|
178
183
|
<dialog
|
|
179
184
|
ref={dialogRef}
|
|
180
185
|
role="alertdialog"
|
|
181
|
-
aria-labelledby=
|
|
182
|
-
aria-describedby={text ?
|
|
186
|
+
aria-labelledby={titleId}
|
|
187
|
+
aria-describedby={text ? descriptionId : undefined}
|
|
183
188
|
onClick={handleBackdropClick}
|
|
184
189
|
className={cn(
|
|
185
190
|
// Reset dialog defaults
|
|
@@ -222,7 +227,7 @@ export function ConfirmDialog({
|
|
|
222
227
|
|
|
223
228
|
{/* Title */}
|
|
224
229
|
<h2
|
|
225
|
-
id=
|
|
230
|
+
id={titleId}
|
|
226
231
|
className={cn(
|
|
227
232
|
"text-sm font-medium leading-snug tracking-tight pr-6",
|
|
228
233
|
// Mobile: centered, Desktop: left-aligned
|
|
@@ -235,7 +240,7 @@ export function ConfirmDialog({
|
|
|
235
240
|
{/* Description (optional) */}
|
|
236
241
|
{text && (
|
|
237
242
|
<p
|
|
238
|
-
id=
|
|
243
|
+
id={descriptionId}
|
|
239
244
|
className="text-sm font-medium leading-snug text-muted-foreground"
|
|
240
245
|
>
|
|
241
246
|
{text}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { ChevronDown } from 'lucide-react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Button } from '../../primitives/button'
|
|
7
|
+
import { useGroupCollapse } from './useGroupCollapse'
|
|
8
|
+
|
|
9
|
+
export interface CollapsibleGroupProps {
|
|
10
|
+
groupId: string
|
|
11
|
+
title?: string
|
|
12
|
+
pageType: string
|
|
13
|
+
defaultExpanded?: boolean
|
|
14
|
+
errorCount?: number
|
|
15
|
+
fieldCount?: number
|
|
16
|
+
chevronPosition?: 'left' | 'right'
|
|
17
|
+
icon?: React.ReactNode
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CollapsibleGroupHandle {
|
|
22
|
+
expand: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, CollapsibleGroupProps>(
|
|
26
|
+
function CollapsibleGroup({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = 'right', icon, children }, ref) {
|
|
27
|
+
const t = useT()
|
|
28
|
+
const { expanded, toggle, setExpanded } = useGroupCollapse(pageType, groupId, defaultExpanded)
|
|
29
|
+
const contentId = `collapsible-group-${groupId}`
|
|
30
|
+
|
|
31
|
+
React.useImperativeHandle(ref, () => ({
|
|
32
|
+
expand: () => setExpanded(true),
|
|
33
|
+
}), [setExpanded])
|
|
34
|
+
|
|
35
|
+
const chevronIcon = (
|
|
36
|
+
<ChevronDown
|
|
37
|
+
className={cn(
|
|
38
|
+
'size-4 shrink-0 motion-safe:transition-transform motion-safe:duration-200',
|
|
39
|
+
expanded && 'rotate-180'
|
|
40
|
+
)}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const fieldCountLabel = typeof fieldCount === 'number' && fieldCount > 0 ? (
|
|
45
|
+
<span className="text-xs font-normal text-muted-foreground">
|
|
46
|
+
· {fieldCount} {fieldCount === 1
|
|
47
|
+
? t('ui.collapsible.fieldSingular', 'field')
|
|
48
|
+
: t('ui.collapsible.fieldPlural', 'fields')}
|
|
49
|
+
</span>
|
|
50
|
+
) : null
|
|
51
|
+
|
|
52
|
+
const errorBadge = errorCount > 0 ? (
|
|
53
|
+
<span className="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
|
54
|
+
{errorCount === 1
|
|
55
|
+
? t('ui.collapsible.errorSingular', '{{count}} error', { count: errorCount })
|
|
56
|
+
: t('ui.collapsible.errorPlural', '{{count}} errors', { count: errorCount })}
|
|
57
|
+
</span>
|
|
58
|
+
) : null
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn('rounded-lg border bg-card', errorCount > 0 && 'border-destructive')}>
|
|
62
|
+
{title && (
|
|
63
|
+
<Button
|
|
64
|
+
type="button"
|
|
65
|
+
variant="muted"
|
|
66
|
+
onClick={toggle}
|
|
67
|
+
className={cn(
|
|
68
|
+
'w-full px-4 py-3 text-sm font-medium hover:bg-accent/50 rounded-lg',
|
|
69
|
+
chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',
|
|
70
|
+
)}
|
|
71
|
+
aria-expanded={expanded}
|
|
72
|
+
aria-controls={contentId}
|
|
73
|
+
>
|
|
74
|
+
{chevronPosition === 'left' ? (
|
|
75
|
+
<>
|
|
76
|
+
{chevronIcon}
|
|
77
|
+
<span className="flex items-center gap-2">
|
|
78
|
+
{icon && <span className="relative shrink-0 text-muted-foreground">{icon}{!expanded && errorCount > 0 && <span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" />}</span>}
|
|
79
|
+
<span>{title}</span>
|
|
80
|
+
{fieldCountLabel}
|
|
81
|
+
{errorBadge}
|
|
82
|
+
</span>
|
|
83
|
+
</>
|
|
84
|
+
) : (
|
|
85
|
+
<>
|
|
86
|
+
<span className="flex items-center gap-2">
|
|
87
|
+
{icon && <span className="relative shrink-0 text-muted-foreground">{icon}{!expanded && errorCount > 0 && <span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" />}</span>}
|
|
88
|
+
<span>{title}</span>
|
|
89
|
+
{fieldCountLabel}
|
|
90
|
+
{errorBadge}
|
|
91
|
+
</span>
|
|
92
|
+
{chevronIcon}
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</Button>
|
|
96
|
+
)}
|
|
97
|
+
<div
|
|
98
|
+
id={contentId}
|
|
99
|
+
className={cn(
|
|
100
|
+
'motion-safe:transition-all motion-safe:duration-200 overflow-hidden',
|
|
101
|
+
expanded ? 'max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<div className="px-4 py-3">
|
|
105
|
+
{children}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { Button } from '../../primitives/button'
|
|
7
|
+
import { useZoneCollapse } from './useZoneCollapse'
|
|
8
|
+
import type { LucideIcon } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
const SIDE_BY_SIDE_MIN_WIDTH = 1280
|
|
11
|
+
|
|
12
|
+
export interface ZoneSectionDescriptor {
|
|
13
|
+
id: string
|
|
14
|
+
icon: LucideIcon
|
|
15
|
+
label: string
|
|
16
|
+
errorCount?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CollapsibleZoneLayoutProps {
|
|
20
|
+
zone1: React.ReactNode
|
|
21
|
+
zone2: React.ReactNode
|
|
22
|
+
entityName: string
|
|
23
|
+
pageType: string
|
|
24
|
+
zone1DefaultWidth?: string
|
|
25
|
+
errorCount?: number
|
|
26
|
+
isDirty?: boolean
|
|
27
|
+
/** Section descriptors for the collapsed rail icon sidebar. When omitted the rail shows the legacy minimal view. */
|
|
28
|
+
sections?: ZoneSectionDescriptor[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function subscribeViewport(callback: () => void) {
|
|
32
|
+
const mediaQuery = window.matchMedia('(min-width: 1024px)')
|
|
33
|
+
mediaQuery.addEventListener('change', callback)
|
|
34
|
+
return () => mediaQuery.removeEventListener('change', callback)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getViewportSnapshot() {
|
|
38
|
+
return window.matchMedia('(min-width: 1024px)').matches
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getViewportServerSnapshot() {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function CollapsibleZoneLayout({
|
|
46
|
+
zone1,
|
|
47
|
+
zone2,
|
|
48
|
+
entityName,
|
|
49
|
+
pageType,
|
|
50
|
+
zone1DefaultWidth,
|
|
51
|
+
errorCount = 0,
|
|
52
|
+
isDirty = false,
|
|
53
|
+
sections,
|
|
54
|
+
}: CollapsibleZoneLayoutProps) {
|
|
55
|
+
const t = useT()
|
|
56
|
+
const { collapsed, setCollapsed } = useZoneCollapse(pageType)
|
|
57
|
+
const canCollapse = React.useSyncExternalStore(
|
|
58
|
+
subscribeViewport,
|
|
59
|
+
getViewportSnapshot,
|
|
60
|
+
getViewportServerSnapshot,
|
|
61
|
+
)
|
|
62
|
+
const layoutRef = React.useRef<HTMLDivElement>(null)
|
|
63
|
+
const expandButtonRef = React.useRef<HTMLButtonElement>(null)
|
|
64
|
+
const [containerWidth, setContainerWidth] = React.useState(() => (typeof window === 'undefined' ? 0 : window.innerWidth))
|
|
65
|
+
const [expandedWhileConstrained, setExpandedWhileConstrained] = React.useState(false)
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
const node = layoutRef.current
|
|
69
|
+
if (!node) return
|
|
70
|
+
|
|
71
|
+
const updateWidth = (nextWidth: number) => {
|
|
72
|
+
setContainerWidth((prev) => (Math.abs(prev - nextWidth) < 1 ? prev : nextWidth))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const measure = () => {
|
|
76
|
+
updateWidth(node.getBoundingClientRect().width || window.innerWidth)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
measure()
|
|
80
|
+
|
|
81
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
82
|
+
window.addEventListener('resize', measure)
|
|
83
|
+
return () => window.removeEventListener('resize', measure)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const observer = new ResizeObserver((entries) => {
|
|
87
|
+
const entry = entries[0]
|
|
88
|
+
if (!entry) return
|
|
89
|
+
updateWidth(entry.contentRect.width)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
observer.observe(node)
|
|
93
|
+
return () => observer.disconnect()
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
const canShowSideBySide = containerWidth >= SIDE_BY_SIDE_MIN_WIDTH
|
|
97
|
+
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
if (canShowSideBySide) {
|
|
100
|
+
setExpandedWhileConstrained(false)
|
|
101
|
+
}
|
|
102
|
+
}, [canShowSideBySide])
|
|
103
|
+
|
|
104
|
+
const showCollapsedRail = canCollapse && (collapsed || (!canShowSideBySide && !expandedWhileConstrained))
|
|
105
|
+
const showStackedExpanded = !showCollapsedRail && !canShowSideBySide
|
|
106
|
+
const layoutMode = showCollapsedRail ? 'collapsed' : showStackedExpanded ? 'stacked' : 'side-by-side'
|
|
107
|
+
const zone1SideBySideStyle = zone1DefaultWidth
|
|
108
|
+
? { width: zone1DefaultWidth, flexBasis: zone1DefaultWidth }
|
|
109
|
+
: undefined
|
|
110
|
+
|
|
111
|
+
const handleExpand = React.useCallback(() => {
|
|
112
|
+
if (!canCollapse) return
|
|
113
|
+
setCollapsed(false)
|
|
114
|
+
setExpandedWhileConstrained(!canShowSideBySide)
|
|
115
|
+
}, [canCollapse, canShowSideBySide, setCollapsed])
|
|
116
|
+
|
|
117
|
+
const handleCollapse = React.useCallback(() => {
|
|
118
|
+
if (!canCollapse) return
|
|
119
|
+
setExpandedWhileConstrained(false)
|
|
120
|
+
setCollapsed(true)
|
|
121
|
+
requestAnimationFrame(() => {
|
|
122
|
+
expandButtonRef.current?.focus()
|
|
123
|
+
})
|
|
124
|
+
}, [canCollapse, setCollapsed])
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={layoutRef}
|
|
129
|
+
data-zone-layout-mode={layoutMode}
|
|
130
|
+
className={cn(
|
|
131
|
+
'flex gap-4',
|
|
132
|
+
showStackedExpanded ? 'flex-col' : 'flex-col lg:flex-row',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{showCollapsedRail ? (
|
|
136
|
+
<>
|
|
137
|
+
<div className="hidden lg:flex shrink-0 flex-col items-center gap-3">
|
|
138
|
+
<Button
|
|
139
|
+
ref={expandButtonRef}
|
|
140
|
+
type="button"
|
|
141
|
+
variant="default"
|
|
142
|
+
size="sm"
|
|
143
|
+
onClick={handleExpand}
|
|
144
|
+
className="h-auto rounded-[10px] px-1.5 py-2 shadow-sm"
|
|
145
|
+
aria-label={t('ui.zone.expand', 'Expand form panel')}
|
|
146
|
+
>
|
|
147
|
+
<ChevronsRight className="size-4" />
|
|
148
|
+
</Button>
|
|
149
|
+
{sections?.length ? (
|
|
150
|
+
<div className="flex flex-col items-center gap-2 rounded-[14px] border border-border/70 bg-card px-2 py-3">
|
|
151
|
+
{sections.map((section) => {
|
|
152
|
+
const SectionIcon = section.icon
|
|
153
|
+
const hasErrors = Boolean(section.errorCount && section.errorCount > 0)
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
key={section.id}
|
|
157
|
+
className="relative flex size-9 items-center justify-center rounded-[10px] border border-transparent bg-muted/70 text-muted-foreground"
|
|
158
|
+
title={section.label}
|
|
159
|
+
>
|
|
160
|
+
<SectionIcon className="size-4" />
|
|
161
|
+
{hasErrors ? (
|
|
162
|
+
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive" />
|
|
163
|
+
) : null}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
{/* Zone 2 takes full width */}
|
|
171
|
+
<div className="min-w-0 flex-1">
|
|
172
|
+
{zone2}
|
|
173
|
+
</div>
|
|
174
|
+
</>
|
|
175
|
+
) : showStackedExpanded ? (
|
|
176
|
+
<>
|
|
177
|
+
<div className="w-full space-y-2">
|
|
178
|
+
{canCollapse ? (
|
|
179
|
+
<div className="flex justify-end">
|
|
180
|
+
<Button
|
|
181
|
+
type="button"
|
|
182
|
+
variant="outline"
|
|
183
|
+
size="sm"
|
|
184
|
+
onClick={handleCollapse}
|
|
185
|
+
className="h-auto rounded-[6px] border bg-card px-1.5 py-2"
|
|
186
|
+
aria-label={t('ui.zone.collapse', 'Collapse form panel')}
|
|
187
|
+
>
|
|
188
|
+
<ChevronsLeft className="size-4" />
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
) : null}
|
|
192
|
+
<div className="w-full">
|
|
193
|
+
{zone1}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="min-w-0 w-full">
|
|
198
|
+
{zone2}
|
|
199
|
+
</div>
|
|
200
|
+
</>
|
|
201
|
+
) : (
|
|
202
|
+
<>
|
|
203
|
+
{/* Zone 1 — CrudForm area */}
|
|
204
|
+
<div
|
|
205
|
+
className={cn('w-full lg:shrink-0', zone1DefaultWidth ? undefined : 'lg:w-[40%]')}
|
|
206
|
+
style={zone1SideBySideStyle}
|
|
207
|
+
>
|
|
208
|
+
{zone1}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Divider with collapse toggle */}
|
|
212
|
+
<div className="hidden lg:flex relative shrink-0 w-8 items-start justify-center pt-4">
|
|
213
|
+
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" />
|
|
214
|
+
<Button
|
|
215
|
+
type="button"
|
|
216
|
+
variant="outline"
|
|
217
|
+
size="sm"
|
|
218
|
+
onClick={handleCollapse}
|
|
219
|
+
className="relative z-10 h-auto rounded-[6px] border bg-card px-1.5 py-2"
|
|
220
|
+
aria-label={t('ui.zone.collapse', 'Collapse form panel')}
|
|
221
|
+
>
|
|
222
|
+
<ChevronsLeft className="size-4" />
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Zone 2 — Tabs / related data area */}
|
|
227
|
+
<div className="min-w-0 w-full lg:flex-1">
|
|
228
|
+
{zone2}
|
|
229
|
+
</div>
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { useGroupCollapse } from '../useGroupCollapse'
|
|
4
|
+
|
|
5
|
+
describe('useGroupCollapse', () => {
|
|
6
|
+
beforeEach(() => { localStorage.clear() })
|
|
7
|
+
|
|
8
|
+
it('defaults to expanded=true', () => {
|
|
9
|
+
const { result } = renderHook(() => useGroupCollapse('page', 'grp1'))
|
|
10
|
+
expect(result.current.expanded).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('honors explicit defaultExpanded=false', () => {
|
|
14
|
+
const { result } = renderHook(() => useGroupCollapse('page', 'grp2', false))
|
|
15
|
+
expect(result.current.expanded).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('writes collapsed state to om:collapsible:<page>:<group>', () => {
|
|
19
|
+
const { result } = renderHook(() => useGroupCollapse('people', 'basics'))
|
|
20
|
+
act(() => { result.current.toggle() })
|
|
21
|
+
expect(result.current.expanded).toBe(false)
|
|
22
|
+
expect(localStorage.getItem('om:collapsible:people:basics')).toBe(JSON.stringify('0'))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('accepts functional setExpanded', () => {
|
|
26
|
+
const { result } = renderHook(() => useGroupCollapse('page', 'grp3'))
|
|
27
|
+
act(() => { result.current.setExpanded((prev) => !prev) })
|
|
28
|
+
expect(result.current.expanded).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('scopes state per (pageType, groupId) pair', () => {
|
|
32
|
+
localStorage.setItem('om:collapsible:p1:g', JSON.stringify('0'))
|
|
33
|
+
const { result: a } = renderHook(() => useGroupCollapse('p1', 'g'))
|
|
34
|
+
const { result: b } = renderHook(() => useGroupCollapse('p2', 'g'))
|
|
35
|
+
expect(a.current.expanded).toBe(false)
|
|
36
|
+
expect(b.current.expanded).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|