@open-mercato/ui 0.5.1-develop.2975.ccbadc8198 → 0.5.1-develop.2996.ce62fd491c

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +274 -697
  3. package/dist/backend/AppShell.js.map +3 -3
  4. package/dist/backend/CrudForm.js +1 -1
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
  7. package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
  8. package/dist/backend/section-page/SectionNav.js +10 -8
  9. package/dist/backend/section-page/SectionNav.js.map +2 -2
  10. package/dist/backend/section-page/SectionPage.js +2 -2
  11. package/dist/backend/section-page/SectionPage.js.map +2 -2
  12. package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
  13. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
  14. package/dist/backend/sidebar/customization-helpers.js +150 -0
  15. package/dist/backend/sidebar/customization-helpers.js.map +7 -0
  16. package/dist/primitives/switch.js +1 -2
  17. package/dist/primitives/switch.js.map +2 -2
  18. package/jest.setup.ts +13 -0
  19. package/package.json +3 -3
  20. package/src/backend/AppShell.tsx +245 -732
  21. package/src/backend/CrudForm.tsx +1 -1
  22. package/src/backend/__tests__/AppShell.test.tsx +1 -1
  23. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
  24. package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
  25. package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
  26. package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
  27. package/src/backend/section-page/SectionNav.tsx +14 -10
  28. package/src/backend/section-page/SectionPage.tsx +15 -10
  29. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
  30. package/src/backend/sidebar/customization-helpers.ts +203 -0
  31. package/src/primitives/switch.tsx +1 -2
@@ -779,7 +779,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
779
779
  // their own protection (or have none by design) keep their pre-existing behavior.
780
780
  if ((embedded && !trackDirtyWhenEmbedded) || !hasUnsavedChanges) return
781
781
  const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
782
- if (!isDirtyRef.current) return
782
+ if (!isDirtyRef.current || submitNavigationBypassRef.current) return
783
783
  event.preventDefault()
784
784
  event.returnValue = ''
785
785
  }
@@ -313,7 +313,7 @@ describe('AppShell', () => {
313
313
  )
314
314
 
315
315
  await waitFor(() => {
316
- expect(screen.getByRole('link', { name: 'User Entities' })).toHaveClass('bg-background')
316
+ expect(screen.getByRole('link', { name: 'User Entities' })).toHaveClass('bg-muted')
317
317
  expect(screen.getByRole('link', { name: 'Calendar Entity' })).toBeInTheDocument()
318
318
  })
319
319
  })
@@ -228,4 +228,105 @@ describe('CollapsibleZoneLayout', () => {
228
228
  })
229
229
  expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
230
230
  })
231
+
232
+ it('focuses the first input field inside the activated section when available', async () => {
233
+ currentWidth = 1180
234
+ Object.defineProperty(window, 'innerWidth', {
235
+ configurable: true,
236
+ writable: true,
237
+ value: currentWidth,
238
+ })
239
+ const scrollIntoView = jest.fn()
240
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
241
+ configurable: true,
242
+ writable: true,
243
+ value: scrollIntoView,
244
+ })
245
+
246
+ const { container } = renderWithProviders(
247
+ <CollapsibleZoneLayout
248
+ zone1={(
249
+ <div id="collapsible-group-wrapper-personalData">
250
+ <button type="button" aria-controls="collapsible-group-personalData" aria-expanded="true">Personal group</button>
251
+ <input type="hidden" name="hidden-field" defaultValue="hidden" />
252
+ <input type="text" name="first-name" placeholder="First name" />
253
+ <input type="text" name="last-name" placeholder="Last name" />
254
+ </div>
255
+ )}
256
+ zone2={<div>Zone 2</div>}
257
+ entityName="Ada Lovelace"
258
+ pageType="person-v2"
259
+ sections={[
260
+ { id: 'personalData', icon: User, label: 'Personal data' },
261
+ ]}
262
+ />,
263
+ { dict: {} },
264
+ )
265
+
266
+ const layout = container.firstElementChild as HTMLElement
267
+
268
+ await waitFor(() => {
269
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
270
+ })
271
+
272
+ fireEvent.click(screen.getByRole('button', { name: 'Personal data' }))
273
+
274
+ await waitFor(() => {
275
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'stacked')
276
+ expect(screen.getByPlaceholderText('First name')).toHaveFocus()
277
+ })
278
+ })
279
+
280
+ it('expands a collapsed inner group when activated from the rail', async () => {
281
+ currentWidth = 1180
282
+ Object.defineProperty(window, 'innerWidth', {
283
+ configurable: true,
284
+ writable: true,
285
+ value: currentWidth,
286
+ })
287
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
288
+ configurable: true,
289
+ writable: true,
290
+ value: jest.fn(),
291
+ })
292
+
293
+ const headingClickHandler = jest.fn()
294
+
295
+ const { container } = renderWithProviders(
296
+ <CollapsibleZoneLayout
297
+ zone1={(
298
+ <div id="collapsible-group-wrapper-personalData">
299
+ <button
300
+ type="button"
301
+ aria-controls="collapsible-group-personalData"
302
+ aria-expanded="false"
303
+ onClick={headingClickHandler}
304
+ >
305
+ Personal group
306
+ </button>
307
+ </div>
308
+ )}
309
+ zone2={<div>Zone 2</div>}
310
+ entityName="Ada Lovelace"
311
+ pageType="person-v2"
312
+ sections={[
313
+ { id: 'personalData', icon: User, label: 'Personal data' },
314
+ ]}
315
+ />,
316
+ { dict: {} },
317
+ )
318
+
319
+ const layout = container.firstElementChild as HTMLElement
320
+
321
+ await waitFor(() => {
322
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
323
+ })
324
+
325
+ fireEvent.click(screen.getByRole('button', { name: 'Personal data' }))
326
+
327
+ await waitFor(() => {
328
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'stacked')
329
+ expect(headingClickHandler).toHaveBeenCalled()
330
+ })
331
+ })
231
332
  })
@@ -153,4 +153,46 @@ describe('CrudForm unsaved navigation guard', () => {
153
153
  expect(confirmDialogMock).not.toHaveBeenCalled()
154
154
  expect(window.location.pathname).toBe('/after-save')
155
155
  })
156
+
157
+ it('suppresses the native beforeunload dialog while the submit-bypass flag is active (regression: #1733)', async () => {
158
+ let beforeUnloadDuringSubmit: BeforeUnloadEvent | null = null
159
+ const onSubmit = jest.fn(async () => {
160
+ const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
161
+ Object.defineProperty(event, 'returnValue', { writable: true, value: undefined })
162
+ window.dispatchEvent(event)
163
+ beforeUnloadDuringSubmit = event
164
+ })
165
+
166
+ const { container } = renderWithProviders(
167
+ <CrudForm title="Form" fields={fields} initialValues={{ name: 'Alice' }} onSubmit={onSubmit} />,
168
+ {
169
+ dict: {
170
+ 'ui.forms.actions.save': 'Save',
171
+ 'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
172
+ },
173
+ },
174
+ )
175
+
176
+ const input = container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
177
+ const form = container.querySelector('form') as HTMLFormElement
178
+
179
+ await act(async () => {
180
+ fireEvent.change(input, { target: { value: 'Alice updated' } })
181
+ })
182
+
183
+ const dirtyEvent = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
184
+ Object.defineProperty(dirtyEvent, 'returnValue', { writable: true, value: undefined })
185
+ window.dispatchEvent(dirtyEvent)
186
+ expect(dirtyEvent.defaultPrevented).toBe(true)
187
+
188
+ await act(async () => {
189
+ fireEvent.submit(form)
190
+ await Promise.resolve()
191
+ })
192
+
193
+ expect(onSubmit).toHaveBeenCalled()
194
+ expect(beforeUnloadDuringSubmit).not.toBeNull()
195
+ expect(beforeUnloadDuringSubmit!.defaultPrevented).toBe(false)
196
+ expect(beforeUnloadDuringSubmit!.returnValue).toBeUndefined()
197
+ })
156
198
  })
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { screen, waitFor } from '@testing-library/react'
7
+ import { SidebarCustomizationEditor } from '../sidebar/SidebarCustomizationEditor'
8
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
9
+
10
+ type ApiCallResult<T> = {
11
+ ok: boolean
12
+ status: number
13
+ result: T | null
14
+ response: unknown
15
+ cacheStatus: 'hit' | 'miss' | null
16
+ }
17
+
18
+ const apiCallMock = jest.fn<Promise<ApiCallResult<unknown>>, [string, RequestInit | undefined]>()
19
+
20
+ jest.mock('../utils/apiCall', () => ({
21
+ apiCall: (...args: unknown[]) => apiCallMock(args[0] as string, args[1] as RequestInit | undefined),
22
+ withScopedApiRequestHeaders: (
23
+ _headers: Record<string, string>,
24
+ operation: () => Promise<unknown>,
25
+ ) => operation(),
26
+ }))
27
+
28
+ const flashMock = jest.fn()
29
+ jest.mock('../FlashMessages', () => ({
30
+ flash: (...args: unknown[]) => flashMock(...args),
31
+ }))
32
+
33
+ jest.mock('next/image', () => (props: { alt?: string }) => {
34
+ const React = require('react')
35
+ return React.createElement('img', { alt: props.alt, ...props })
36
+ })
37
+
38
+ jest.mock('../BackendChromeProvider', () => ({
39
+ useBackendChrome: () => ({ payload: null, isLoading: false }),
40
+ }))
41
+
42
+ jest.mock('../injection/resolveInjectedIcon', () => ({
43
+ resolveInjectedIcon: () => null,
44
+ }))
45
+
46
+ jest.mock('../injection/InjectionSpot', () => ({
47
+ InjectionSpot: () => null,
48
+ useInjectionSpotEvents: () => ({
49
+ triggerEvent: jest.fn(async () => ({ ok: true, requestHeaders: {} })),
50
+ }),
51
+ }))
52
+
53
+ jest.mock('../injection/mutationEvents', () => ({
54
+ GLOBAL_MUTATION_INJECTION_SPOT_ID: 'backend-mutation:global',
55
+ dispatchBackendMutationError: jest.fn(),
56
+ }))
57
+
58
+ const fakeGroups = [
59
+ {
60
+ id: 'core',
61
+ name: 'Core',
62
+ items: [
63
+ { href: '/backend/users', title: 'Users' },
64
+ { href: '/backend/roles', title: 'Roles' },
65
+ ],
66
+ },
67
+ {
68
+ id: 'catalog',
69
+ name: 'Catalog',
70
+ items: [
71
+ { href: '/backend/products', title: 'Products' },
72
+ ],
73
+ },
74
+ ]
75
+
76
+ const dict: Record<string, string> = {
77
+ 'appShell.sidebarCustomizationHeading': 'Sidebar customization',
78
+ 'appShell.sidebarCustomizationLoading': 'Loading preferences…',
79
+ 'appShell.sidebarCustomizationLoadError': 'We couldn’t load your sidebar preferences.',
80
+ 'appShell.sidebarCustomizationSave': 'Save',
81
+ 'appShell.sidebarCustomizationCancel': 'Cancel',
82
+ 'appShell.sidebarCustomizationReset': 'Reset',
83
+ 'appShell.sidebarCustomizationDragToReorder': 'Drag to reorder',
84
+ 'appShell.sidebarCustomizationVariantNew': 'Add new variant',
85
+ 'appShell.sidebarCustomizationVariantsEmpty': 'No saved variants yet',
86
+ }
87
+
88
+ function setApiCallSequence(responses: Array<{ url: RegExp; response: ApiCallResult<unknown> }>) {
89
+ apiCallMock.mockImplementation((url: string) => {
90
+ const match = responses.find((entry) => entry.url.test(url))
91
+ if (!match) {
92
+ throw new Error(`apiCall mock: no response configured for ${url}`)
93
+ }
94
+ return Promise.resolve(match.response)
95
+ })
96
+ }
97
+
98
+ function okResult<T>(result: T): ApiCallResult<T> {
99
+ return { ok: true, status: 200, result, response: {}, cacheStatus: null }
100
+ }
101
+
102
+ function errorResult(status: number): ApiCallResult<unknown> {
103
+ return { ok: false, status, result: null, response: {}, cacheStatus: null }
104
+ }
105
+
106
+ beforeEach(() => {
107
+ apiCallMock.mockReset()
108
+ flashMock.mockReset()
109
+ })
110
+
111
+ describe('SidebarCustomizationEditor', () => {
112
+ it('shows the loading skeleton before async data resolves', () => {
113
+ setApiCallSequence([
114
+ { url: /\/api\/auth\/sidebar\/variants/, response: okResult({ locale: 'en', variants: [] }) },
115
+ { url: /\/api\/auth\/sidebar\/preferences/, response: okResult({ canApplyToRoles: false, roles: [] }) },
116
+ ])
117
+
118
+ const { container } = renderWithProviders(
119
+ <SidebarCustomizationEditor groups={fakeGroups} />,
120
+ { dict },
121
+ )
122
+
123
+ expect(container.querySelector('.animate-pulse')).not.toBeNull()
124
+ expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument()
125
+ })
126
+
127
+ it('renders draggable item handles after variants load', async () => {
128
+ setApiCallSequence([
129
+ { url: /\/api\/auth\/sidebar\/variants/, response: okResult({ locale: 'en', variants: [] }) },
130
+ { url: /\/api\/auth\/sidebar\/preferences/, response: okResult({ canApplyToRoles: false, roles: [] }) },
131
+ ])
132
+
133
+ renderWithProviders(<SidebarCustomizationEditor groups={fakeGroups} />, { dict })
134
+
135
+ await waitFor(() => {
136
+ expect(screen.queryByText('Loading preferences…')).not.toBeInTheDocument()
137
+ })
138
+
139
+ const dragHandles = await screen.findAllByLabelText('Drag to reorder')
140
+ expect(dragHandles.length).toBeGreaterThan(0)
141
+
142
+ expect(screen.getByText('Variant name')).toBeInTheDocument()
143
+ })
144
+
145
+ it('surfaces a load error when the variants endpoint fails', async () => {
146
+ setApiCallSequence([
147
+ { url: /\/api\/auth\/sidebar\/variants/, response: errorResult(500) },
148
+ { url: /\/api\/auth\/sidebar\/preferences/, response: okResult({ canApplyToRoles: false, roles: [] }) },
149
+ ])
150
+
151
+ renderWithProviders(<SidebarCustomizationEditor groups={fakeGroups} />, { dict })
152
+
153
+ await waitFor(() => {
154
+ expect(screen.getByText('We couldn’t load your sidebar preferences.')).toBeInTheDocument()
155
+ })
156
+ })
157
+
158
+ it('renders the role-apply target list when canApplyToRoles is true', async () => {
159
+ setApiCallSequence([
160
+ { url: /\/api\/auth\/sidebar\/variants/, response: okResult({ locale: 'en', variants: [] }) },
161
+ {
162
+ url: /\/api\/auth\/sidebar\/preferences/,
163
+ response: okResult({
164
+ canApplyToRoles: true,
165
+ roles: [
166
+ { id: 'role-staff', name: 'Staff', hasPreference: false },
167
+ { id: 'role-admin', name: 'Admin', hasPreference: true },
168
+ ],
169
+ }),
170
+ },
171
+ ])
172
+
173
+ renderWithProviders(<SidebarCustomizationEditor groups={fakeGroups} />, { dict })
174
+
175
+ await waitFor(() => {
176
+ expect(screen.getByText('Staff')).toBeInTheDocument()
177
+ })
178
+ expect(screen.getByText('Admin')).toBeInTheDocument()
179
+ })
180
+
181
+ it('does not render the role-apply target list when canApplyToRoles is false', async () => {
182
+ setApiCallSequence([
183
+ { url: /\/api\/auth\/sidebar\/variants/, response: okResult({ locale: 'en', variants: [] }) },
184
+ {
185
+ url: /\/api\/auth\/sidebar\/preferences/,
186
+ response: okResult({
187
+ canApplyToRoles: false,
188
+ roles: [{ id: 'role-staff', name: 'Staff', hasPreference: false }],
189
+ }),
190
+ },
191
+ ])
192
+
193
+ renderWithProviders(<SidebarCustomizationEditor groups={fakeGroups} />, { dict })
194
+
195
+ await waitFor(() => {
196
+ expect(screen.queryByText('Loading preferences…')).not.toBeInTheDocument()
197
+ })
198
+ expect(screen.queryByText('Staff')).not.toBeInTheDocument()
199
+ })
200
+ })
@@ -125,9 +125,34 @@ export function CollapsibleZoneLayout({
125
125
  const target =
126
126
  document.getElementById(section.targetId ?? `collapsible-group-wrapper-${section.id}`)
127
127
  ?? document.getElementById(`collapsible-group-${section.id}`)
128
- const headingButton = target?.querySelector<HTMLButtonElement>('button[aria-controls]')
129
- target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
130
- headingButton?.focus({ preventScroll: true })
128
+ if (!target) return
129
+ const headingButton = target.querySelector<HTMLButtonElement>('button[aria-controls]')
130
+ // If the inner CollapsibleGroup is currently collapsed, expand it so its
131
+ // contents become visible and tabbable for the user who just navigated here.
132
+ if (headingButton?.getAttribute('aria-expanded') === 'false') {
133
+ headingButton.click()
134
+ }
135
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
136
+ // Prefer focusing the first focusable input/textarea/select inside the
137
+ // section so the user can start typing immediately. Skip hidden, disabled,
138
+ // or non-interactive controls. Fall back to the section heading.
139
+ requestAnimationFrame(() => {
140
+ const focusables = Array.from(
141
+ target.querySelectorAll<HTMLElement>(
142
+ 'input:not([type="hidden"]), textarea, select, [contenteditable="true"]',
143
+ ),
144
+ )
145
+ const firstInput = focusables.find((el) => {
146
+ if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false
147
+ if (el instanceof HTMLInputElement && el.readOnly) return false
148
+ return true
149
+ })
150
+ if (firstInput) {
151
+ firstInput.focus({ preventScroll: true })
152
+ return
153
+ }
154
+ headingButton?.focus({ preventScroll: true })
155
+ })
131
156
  })
132
157
  }, [canCollapse, canShowSideBySide, setCollapsed])
133
158
 
@@ -53,22 +53,25 @@ export function SectionNav({
53
53
  const renderItem = (item: SectionNavItem) => {
54
54
  const isActive = activePath === item.href || activePath.startsWith(item.href + '/')
55
55
  const label = item.labelKey ? t(item.labelKey, item.label) : item.label
56
+ const base = collapsed ? 'w-10 h-10 justify-center' : 'w-full py-2 gap-2'
57
+ const spacingStyle = !collapsed ? { paddingLeft: '12px', paddingRight: '12px' } : undefined
56
58
 
57
59
  return (
58
60
  <Link
59
61
  key={item.id}
60
62
  href={item.href}
61
- className={`relative text-sm rounded px-3 py-1.5 flex items-center gap-2 transition-colors ${
63
+ className={`relative text-sm font-medium rounded-lg inline-flex items-center transition-colors ${base} ${
62
64
  isActive
63
- ? 'bg-background border shadow-sm font-medium'
64
- : 'hover:bg-accent hover:text-accent-foreground'
65
+ ? 'bg-muted text-foreground'
66
+ : 'text-muted-foreground hover:bg-muted'
65
67
  }`}
68
+ style={spacingStyle}
66
69
  title={collapsed ? label : undefined}
67
70
  >
68
71
  {isActive && (
69
- <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
72
+ <span aria-hidden className={`absolute ${collapsed ? 'left-[-20px]' : 'left-[-12px]'} top-2 w-1 h-5 rounded-r bg-foreground`} />
70
73
  )}
71
- <span className="flex items-center justify-center shrink-0 text-muted-foreground">
74
+ <span className="flex items-center justify-center shrink-0">
72
75
  {item.icon ?? DefaultIcon}
73
76
  </span>
74
77
  {!collapsed && <span className="truncate">{label}</span>}
@@ -103,11 +106,13 @@ export function SectionNav({
103
106
  return (
104
107
  <div key={section.id} className="flex flex-col gap-1">
105
108
  {!collapsed && (
106
- <div className="px-3 py-1.5 text-xs uppercase text-muted-foreground/80 font-medium">
109
+ <div className="w-full px-1 py-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
107
110
  {sectionLabel}
108
111
  </div>
109
112
  )}
110
- {sortedItems.map(renderItem)}
113
+ <div className={`flex flex-col ${collapsed ? 'items-center' : ''} gap-1`}>
114
+ {sortedItems.map(renderItem)}
115
+ </div>
111
116
  </div>
112
117
  )
113
118
  }
@@ -115,7 +120,7 @@ export function SectionNav({
115
120
  const sortedSections = [...sections].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
116
121
 
117
122
  return (
118
- <nav className={`flex flex-col gap-4 p-3 ${collapsed ? 'items-center' : ''}`}>
123
+ <nav className={`flex flex-col gap-3 ${collapsed ? 'items-center' : ''}`}>
119
124
  <div className={`flex items-center ${collapsed ? 'justify-center' : 'justify-between'} gap-2`}>
120
125
  {!collapsed && (
121
126
  <span className="text-sm font-medium truncate">{resolvedTitle}</span>
@@ -131,8 +136,7 @@ export function SectionNav({
131
136
  {collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
132
137
  </IconButton>
133
138
  </div>
134
- <div className="border-t" />
135
- <div className={`flex flex-col gap-4 ${collapsed ? 'items-center' : ''}`}>
139
+ <div className={`flex flex-col gap-2 ${collapsed ? 'items-center' : ''}`}>
136
140
  {sortedSections.map(renderSection)}
137
141
  </div>
138
142
  </nav>
@@ -15,16 +15,21 @@ export function SectionPage({
15
15
 
16
16
  return (
17
17
  <div className="flex h-full min-h-[calc(100vh-8rem)]">
18
- <aside className={`${collapsed ? 'w-16' : 'w-64'} border-r bg-muted/30 overflow-y-auto shrink-0 transition-all duration-200`}>
19
- <SectionNav
20
- title={title}
21
- titleKey={titleKey}
22
- sections={sections}
23
- activePath={activePath}
24
- userFeatures={userFeatures}
25
- collapsed={collapsed}
26
- onToggleCollapse={() => setCollapsed(!collapsed)}
27
- />
18
+ <aside className={`${collapsed ? 'w-16' : 'w-64'} border-r bg-background shrink-0 py-4 transition-all duration-200`}>
19
+ {/* Padding lives on the inner scroll container so the absolute active-marker
20
+ (left: -12px from each link) renders inside the inner div's padding box —
21
+ CSS clips at padding-box edges, so a marker placed there stays visible. */}
22
+ <div className={`h-full overflow-y-auto scrollbar-hide ${collapsed ? 'pl-2 pr-1' : 'pl-3 pr-1'}`}>
23
+ <SectionNav
24
+ title={title}
25
+ titleKey={titleKey}
26
+ sections={sections}
27
+ activePath={activePath}
28
+ userFeatures={userFeatures}
29
+ collapsed={collapsed}
30
+ onToggleCollapse={() => setCollapsed(!collapsed)}
31
+ />
32
+ </div>
28
33
  </aside>
29
34
  <main className="flex-1 overflow-y-auto p-6">
30
35
  {children}