@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +274 -697
- package/dist/backend/AppShell.js.map +3 -3
- package/dist/backend/CrudForm.js +1 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
- package/dist/backend/section-page/SectionNav.js +10 -8
- package/dist/backend/section-page/SectionNav.js.map +2 -2
- package/dist/backend/section-page/SectionPage.js +2 -2
- package/dist/backend/section-page/SectionPage.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
- package/dist/backend/sidebar/customization-helpers.js +150 -0
- package/dist/backend/sidebar/customization-helpers.js.map +7 -0
- package/dist/primitives/switch.js +1 -2
- package/dist/primitives/switch.js.map +2 -2
- package/jest.setup.ts +13 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +245 -732
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
- package/src/backend/section-page/SectionNav.tsx +14 -10
- package/src/backend/section-page/SectionPage.tsx +15 -10
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
- package/src/backend/sidebar/customization-helpers.ts +203 -0
- package/src/primitives/switch.tsx +1 -2
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
63
|
+
className={`relative text-sm font-medium rounded-lg inline-flex items-center transition-colors ${base} ${
|
|
62
64
|
isActive
|
|
63
|
-
? 'bg-
|
|
64
|
-
: '
|
|
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=
|
|
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
|
|
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-
|
|
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
|
-
{
|
|
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-
|
|
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=
|
|
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-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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}
|