@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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/CrudForm.js +187 -39
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/Page.js +12 -4
  5. package/dist/backend/Page.js.map +2 -2
  6. package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
  7. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  8. package/dist/backend/crud/CollapsibleGroup.js +88 -0
  9. package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
  10. package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
  11. package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
  12. package/dist/backend/crud/useGroupCollapse.js +24 -0
  13. package/dist/backend/crud/useGroupCollapse.js.map +7 -0
  14. package/dist/backend/crud/useGroupOrder.js +61 -0
  15. package/dist/backend/crud/useGroupOrder.js.map +7 -0
  16. package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
  17. package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
  18. package/dist/backend/crud/useZoneCollapse.js +24 -0
  19. package/dist/backend/crud/useZoneCollapse.js.map +7 -0
  20. package/dist/backend/detail/AttachmentsSection.js +77 -33
  21. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  22. package/dist/backend/detail/NotesSection.js +82 -6
  23. package/dist/backend/detail/NotesSection.js.map +2 -2
  24. package/dist/backend/icons/lucideRegistry.generated.js +16 -2
  25. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  26. package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
  27. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  28. package/dist/primitives/avatar.js +59 -0
  29. package/dist/primitives/avatar.js.map +7 -0
  30. package/package.json +3 -3
  31. package/src/backend/CrudForm.tsx +230 -21
  32. package/src/backend/Page.tsx +20 -4
  33. package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
  34. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
  35. package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
  36. package/src/backend/__tests__/NotesSection.test.tsx +63 -0
  37. package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
  38. package/src/backend/crud/CollapsibleGroup.tsx +111 -0
  39. package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
  40. package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
  41. package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
  42. package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
  43. package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
  44. package/src/backend/crud/useGroupCollapse.ts +22 -0
  45. package/src/backend/crud/useGroupOrder.ts +74 -0
  46. package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
  47. package/src/backend/crud/useZoneCollapse.ts +22 -0
  48. package/src/backend/detail/AttachmentsSection.tsx +81 -38
  49. package/src/backend/detail/NotesSection.tsx +99 -6
  50. package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
  51. package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
  52. package/src/primitives/__tests__/avatar.test.tsx +64 -0
  53. 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-red-600')).toHaveTextContent('This field is required')
68
- expect(gatewayField?.querySelector('.text-xs.text-red-600')).toHaveTextContent('This field is required')
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-red-600')).toBeNull()
77
- expect(gatewayField?.querySelector('.text-xs.text-red-600')).toHaveTextContent('This field is required')
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="confirm-dialog-title"
182
- aria-describedby={text ? "confirm-dialog-description" : undefined}
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="confirm-dialog-title"
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="confirm-dialog-description"
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
+ })