@actuate-media/cms-admin 0.72.2 → 0.73.0
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/CHANGELOG.md +39 -0
- package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
- package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
- package/dist/__tests__/components/resizable-aside.test.js +77 -0
- package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
- package/dist/__tests__/components/toggle.test.d.ts +2 -0
- package/dist/__tests__/components/toggle.test.d.ts.map +1 -0
- package/dist/__tests__/components/toggle.test.js +17 -0
- package/dist/__tests__/components/toggle.test.js.map +1 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js +38 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/TagInput.js +1 -1
- package/dist/components/TagInput.js.map +1 -1
- package/dist/components/ui/Modal.d.ts +17 -1
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +29 -3
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/components/ui/ResizableAside.d.ts +38 -0
- package/dist/components/ui/ResizableAside.d.ts.map +1 -0
- package/dist/components/ui/ResizableAside.js +209 -0
- package/dist/components/ui/ResizableAside.js.map +1 -0
- package/dist/components/ui/Toggle.d.ts.map +1 -1
- package/dist/components/ui/Toggle.js +4 -3
- package/dist/components/ui/Toggle.js.map +1 -1
- package/dist/fields/RichTextField.d.ts +3 -1
- package/dist/fields/RichTextField.d.ts.map +1 -1
- package/dist/fields/RichTextField.js +2 -2
- package/dist/fields/RichTextField.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +1 -1
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +1 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts +4 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +14 -3
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +152 -13
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +2 -1
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +6 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/resizable-aside.test.tsx +130 -0
- package/src/__tests__/components/toggle.test.tsx +17 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +54 -0
- package/src/components/MediaPickerModal.tsx +6 -2
- package/src/components/TagInput.tsx +1 -1
- package/src/components/ui/Modal.tsx +46 -3
- package/src/components/ui/ResizableAside.tsx +317 -0
- package/src/components/ui/Toggle.tsx +4 -3
- package/src/fields/RichTextField.tsx +4 -1
- package/src/styles/actuate-tokens.css +7 -2
- package/src/views/Dashboard.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +1 -1
- package/src/views/page-editor/SectionInspector.tsx +54 -26
- package/src/views/post-editor/PostFieldsModal.tsx +412 -174
- package/src/views/post-editor/PostHeaderPanel.tsx +15 -11
- package/src/views/post-editor/PostSectionEditor.tsx +9 -1
- package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
InspectorExpandButton,
|
|
6
|
+
ResizableAside,
|
|
7
|
+
SECTION_INSPECTOR_DEFAULT_WIDTH,
|
|
8
|
+
SECTION_INSPECTOR_MIN_WIDTH,
|
|
9
|
+
SECTION_INSPECTOR_WIDTH_KEY,
|
|
10
|
+
computeInspectorMaxWidth,
|
|
11
|
+
computeInspectorOverlayWidth,
|
|
12
|
+
readSectionInspectorWidth,
|
|
13
|
+
} from '../../components/ui/ResizableAside.js'
|
|
14
|
+
|
|
15
|
+
const storage = new Map<string, string>()
|
|
16
|
+
vi.stubGlobal('localStorage', {
|
|
17
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
18
|
+
setItem: (key: string, value: string) => void storage.set(key, String(value)),
|
|
19
|
+
removeItem: (key: string) => void storage.delete(key),
|
|
20
|
+
clear: () => storage.clear(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
vi.stubGlobal(
|
|
24
|
+
'ResizeObserver',
|
|
25
|
+
class {
|
|
26
|
+
constructor(private callback: ResizeObserverCallback) {}
|
|
27
|
+
observe(target: Element) {
|
|
28
|
+
const width =
|
|
29
|
+
target instanceof HTMLElement
|
|
30
|
+
? Number.parseFloat(target.style.width) || target.clientWidth || 900
|
|
31
|
+
: 900
|
|
32
|
+
this.callback([{ contentRect: { width, height: 600 } } as ResizeObserverEntry], this)
|
|
33
|
+
}
|
|
34
|
+
unobserve() {}
|
|
35
|
+
disconnect() {}
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
storage.clear()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('inspector width helpers', () => {
|
|
44
|
+
it('caps inline max width to the container minus a canvas peek', () => {
|
|
45
|
+
expect(computeInspectorMaxWidth(1200)).toBe(640)
|
|
46
|
+
expect(computeInspectorMaxWidth(500)).toBe(404)
|
|
47
|
+
expect(computeInspectorMaxWidth(200)).toBe(SECTION_INSPECTOR_MIN_WIDTH)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('computes overlay width for narrow editor rows', () => {
|
|
51
|
+
expect(computeInspectorOverlayWidth(375)).toBe(343)
|
|
52
|
+
expect(computeInspectorOverlayWidth(768)).toBe(480)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('ResizableAside', () => {
|
|
57
|
+
it('renders a vertical resize separator with keyboard support', () => {
|
|
58
|
+
render(
|
|
59
|
+
<div style={{ width: 900 }}>
|
|
60
|
+
<ResizableAside aria-label="Section editor">
|
|
61
|
+
<p>Inspector content</p>
|
|
62
|
+
</ResizableAside>
|
|
63
|
+
</div>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('Inspector content')).toBeTruthy()
|
|
67
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
68
|
+
expect(separator.getAttribute('aria-orientation')).toBe('vertical')
|
|
69
|
+
expect(separator.getAttribute('aria-valuemin')).toBe(String(SECTION_INSPECTOR_MIN_WIDTH))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('widens when dragging the separator left', () => {
|
|
73
|
+
render(
|
|
74
|
+
<div style={{ width: 900 }}>
|
|
75
|
+
<ResizableAside aria-label="Section editor">
|
|
76
|
+
<p>Inspector content</p>
|
|
77
|
+
</ResizableAside>
|
|
78
|
+
</div>,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const aside = screen.getByLabelText('Section editor')
|
|
82
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
83
|
+
|
|
84
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH}px`)
|
|
85
|
+
|
|
86
|
+
fireEvent.pointerDown(separator, { clientX: 900, button: 0, pointerId: 1 })
|
|
87
|
+
fireEvent.pointerMove(separator, { clientX: 860, pointerId: 1 })
|
|
88
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 40}px`)
|
|
89
|
+
|
|
90
|
+
fireEvent.pointerUp(separator, { pointerId: 1 })
|
|
91
|
+
expect(localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)).toBe(
|
|
92
|
+
String(SECTION_INSPECTOR_DEFAULT_WIDTH + 40),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('nudges width with arrow keys and persists', () => {
|
|
97
|
+
render(
|
|
98
|
+
<div style={{ width: 900 }}>
|
|
99
|
+
<ResizableAside aria-label="Section editor">
|
|
100
|
+
<p>Inspector content</p>
|
|
101
|
+
</ResizableAside>
|
|
102
|
+
</div>,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const aside = screen.getByLabelText('Section editor')
|
|
106
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
107
|
+
|
|
108
|
+
separator.focus()
|
|
109
|
+
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
|
|
110
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 16}px`)
|
|
111
|
+
expect(readSectionInspectorWidth()).toBe(SECTION_INSPECTOR_DEFAULT_WIDTH + 16)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('expands to overlay width on narrow containers', () => {
|
|
115
|
+
render(
|
|
116
|
+
<div style={{ width: 375 }}>
|
|
117
|
+
<ResizableAside aria-label="Section editor">
|
|
118
|
+
<InspectorExpandButton />
|
|
119
|
+
<p>Inspector content</p>
|
|
120
|
+
</ResizableAside>
|
|
121
|
+
</div>,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const aside = screen.getByLabelText('Section editor')
|
|
125
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand inspector panel' }))
|
|
126
|
+
|
|
127
|
+
expect(aside.getAttribute('data-overlay')).toBe('')
|
|
128
|
+
expect(aside.style.width).toBe(`${computeInspectorOverlayWidth(375)}px`)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { Toggle } from '../../components/ui/Toggle.js'
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup)
|
|
7
|
+
|
|
8
|
+
describe('Toggle', () => {
|
|
9
|
+
it('positions the thumb with design-system absolute offsets', () => {
|
|
10
|
+
render(<Toggle checked={false} onCheckedChange={() => {}} aria-label="Test toggle" />)
|
|
11
|
+
const thumb = screen.getByRole('switch').querySelector('span')
|
|
12
|
+
expect(thumb?.className).toContain('absolute')
|
|
13
|
+
expect(thumb?.className).toContain('top-[2.5px]')
|
|
14
|
+
expect(thumb?.className).toContain('left-[2.5px]')
|
|
15
|
+
expect(thumb?.className).toContain('data-[state=checked]:left-[19.5px]')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -21,6 +21,17 @@ vi.mock('../../fields/RichTextField.js', () => ({
|
|
|
21
21
|
RichTextField: () => <div data-testid="rich-text" />,
|
|
22
22
|
}))
|
|
23
23
|
|
|
24
|
+
vi.mock('../../lib/coauthor-client.js', () => ({
|
|
25
|
+
coauthorText: vi.fn(async () => ({
|
|
26
|
+
ok: true,
|
|
27
|
+
result: { action: 'compress', text: 'AI excerpt' },
|
|
28
|
+
})),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
32
|
+
generateSeoField: vi.fn(async () => ({ text: 'AI SEO title' })),
|
|
33
|
+
}))
|
|
34
|
+
|
|
24
35
|
const { PostFieldsModal } = await import('../../views/post-editor/PostFieldsModal.js')
|
|
25
36
|
type ModalProps = Parameters<typeof PostFieldsModal>[0]
|
|
26
37
|
|
|
@@ -123,4 +134,47 @@ describe('PostFieldsModal', () => {
|
|
|
123
134
|
expect.objectContaining({ category: 'tutorials', tags: ['AI', 'CMS'] }),
|
|
124
135
|
)
|
|
125
136
|
})
|
|
137
|
+
|
|
138
|
+
it('derives slug from title until the slug field is edited', () => {
|
|
139
|
+
render(
|
|
140
|
+
<PostFieldsModal
|
|
141
|
+
open
|
|
142
|
+
post={basePost({ title: '', slug: '' })}
|
|
143
|
+
canEdit
|
|
144
|
+
onClose={() => {}}
|
|
145
|
+
onSave={() => {}}
|
|
146
|
+
/>,
|
|
147
|
+
)
|
|
148
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Hello World' } })
|
|
149
|
+
expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('hello-world')
|
|
150
|
+
fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
|
|
151
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed Title' } })
|
|
152
|
+
expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('custom-slug')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('exposes AI generate controls for excerpt and SEO fields', () => {
|
|
156
|
+
render(<PostFieldsModal open post={basePost()} canEdit onClose={() => {}} onSave={() => {}} />)
|
|
157
|
+
expect(screen.getByRole('button', { name: 'Generate excerpt from content' })).toBeTruthy()
|
|
158
|
+
expect(screen.getByRole('button', { name: 'Generate SEO title from content' })).toBeTruthy()
|
|
159
|
+
expect(
|
|
160
|
+
screen.getByRole('button', { name: 'Generate SEO description from content' }),
|
|
161
|
+
).toBeTruthy()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('prompts before discarding when Cancel is clicked with unsaved edits', () => {
|
|
165
|
+
const onClose = vi.fn()
|
|
166
|
+
render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
|
|
167
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed title' } })
|
|
168
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
169
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
170
|
+
expect(screen.getByRole('dialog', { name: 'Discard unsaved changes?' })).toBeTruthy()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('closes without prompting when nothing changed', () => {
|
|
174
|
+
const onClose = vi.fn()
|
|
175
|
+
render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
|
|
176
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
177
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
178
|
+
expect(screen.queryByRole('dialog', { name: 'Discard unsaved changes?' })).toBeNull()
|
|
179
|
+
})
|
|
126
180
|
})
|
|
@@ -129,10 +129,14 @@ export function MediaPickerModal({
|
|
|
129
129
|
return (
|
|
130
130
|
<Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
|
|
131
131
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
132
|
-
|
|
132
|
+
{/*
|
|
133
|
+
z-70 so this picker stacks above the z-60 form modals that embed it
|
|
134
|
+
(PostFieldsModal featured image, TipTap insert image, etc.).
|
|
135
|
+
*/}
|
|
136
|
+
<Dialog.Overlay className="fixed inset-0 z-70 bg-black/40" />
|
|
133
137
|
<Dialog.Content
|
|
134
138
|
aria-describedby={undefined}
|
|
135
|
-
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-
|
|
139
|
+
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-70 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
|
|
136
140
|
>
|
|
137
141
|
<div className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
138
142
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -35,7 +35,7 @@ interface FieldValuesResponse {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const INPUT =
|
|
38
|
-
'border-
|
|
38
|
+
'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
39
39
|
|
|
40
40
|
export function TagInput({
|
|
41
41
|
id,
|
|
@@ -5,12 +5,23 @@ import { X } from 'lucide-react'
|
|
|
5
5
|
import { type ReactNode, type RefObject } from 'react'
|
|
6
6
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
7
7
|
|
|
8
|
+
const MODAL_SIZE_CLASS = {
|
|
9
|
+
md: 'max-w-lg',
|
|
10
|
+
lg: 'max-w-3xl',
|
|
11
|
+
/** Wide form dialogs (post/page field editors) — body scrolls inside the shell. */
|
|
12
|
+
xl: 'flex max-h-[min(92vh,880px)] max-w-5xl flex-col',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export type ModalSize = keyof typeof MODAL_SIZE_CLASS
|
|
16
|
+
|
|
8
17
|
export interface ModalProps {
|
|
9
18
|
open: boolean
|
|
10
19
|
onClose: () => void
|
|
11
20
|
title: string
|
|
12
21
|
children: ReactNode
|
|
13
22
|
actions?: ReactNode
|
|
23
|
+
/** Dialog width preset. Defaults to `md` (512px). */
|
|
24
|
+
size?: ModalSize
|
|
14
25
|
/**
|
|
15
26
|
* Accessible description for the dialog, announced after the title. Optional —
|
|
16
27
|
* omit when the body content is self-explanatory.
|
|
@@ -22,6 +33,12 @@ export interface ModalProps {
|
|
|
22
33
|
* form dialogs where the user should land in the first input.
|
|
23
34
|
*/
|
|
24
35
|
initialFocusRef?: RefObject<HTMLElement | null>
|
|
36
|
+
/**
|
|
37
|
+
* When set, overlay click, Escape, and the header X invoke this instead of
|
|
38
|
+
* closing immediately — use to confirm before discarding unsaved edits.
|
|
39
|
+
* The dialog stays open until the parent calls `onClose()`.
|
|
40
|
+
*/
|
|
41
|
+
onDismiss?: () => void
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
/**
|
|
@@ -41,15 +58,39 @@ export function Modal({
|
|
|
41
58
|
title,
|
|
42
59
|
children,
|
|
43
60
|
actions,
|
|
61
|
+
size = 'md',
|
|
44
62
|
description,
|
|
45
63
|
initialFocusRef,
|
|
64
|
+
onDismiss,
|
|
46
65
|
}: ModalProps) {
|
|
66
|
+
const scrollBody = size === 'xl'
|
|
67
|
+
|
|
68
|
+
function handleDismissAttempt() {
|
|
69
|
+
if (onDismiss) onDismiss()
|
|
70
|
+
else onClose()
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
return (
|
|
48
|
-
<Dialog.Root
|
|
74
|
+
<Dialog.Root
|
|
75
|
+
open={open}
|
|
76
|
+
onOpenChange={(next) => {
|
|
77
|
+
if (!next) handleDismissAttempt()
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
49
80
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
50
81
|
<Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
|
|
51
82
|
<Dialog.Content
|
|
52
|
-
className=
|
|
83
|
+
className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
|
|
84
|
+
onInteractOutside={(event) => {
|
|
85
|
+
if (!onDismiss) return
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
handleDismissAttempt()
|
|
88
|
+
}}
|
|
89
|
+
onEscapeKeyDown={(event) => {
|
|
90
|
+
if (!onDismiss) return
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
handleDismissAttempt()
|
|
93
|
+
}}
|
|
53
94
|
onOpenAutoFocus={
|
|
54
95
|
initialFocusRef
|
|
55
96
|
? (event) => {
|
|
@@ -78,7 +119,9 @@ export function Modal({
|
|
|
78
119
|
{description}
|
|
79
120
|
</Dialog.Description>
|
|
80
121
|
)}
|
|
81
|
-
<div className=
|
|
122
|
+
<div className={scrollBody ? 'min-h-0 flex-1 overflow-y-auto px-6 py-4' : 'px-6 py-4'}>
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
82
125
|
{actions && (
|
|
83
126
|
<div className="border-border flex justify-end gap-2 border-t px-6 py-4">{actions}</div>
|
|
84
127
|
)}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ComponentPropsWithoutRef,
|
|
11
|
+
type KeyboardEvent,
|
|
12
|
+
type PointerEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import { Maximize2, Minimize2 } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
export const SECTION_INSPECTOR_WIDTH_KEY = 'actuate:section-inspector-width'
|
|
18
|
+
export const SECTION_INSPECTOR_DEFAULT_WIDTH = 320
|
|
19
|
+
export const SECTION_INSPECTOR_MIN_WIDTH = 280
|
|
20
|
+
export const SECTION_INSPECTOR_MAX_WIDTH = 640
|
|
21
|
+
export const SECTION_INSPECTOR_MIN_CANVAS_WIDTH = 96
|
|
22
|
+
/** Below this editor-row width the expand action uses an overlay panel. */
|
|
23
|
+
export const SECTION_INSPECTOR_OVERLAY_BREAKPOINT = 768
|
|
24
|
+
export const SECTION_INSPECTOR_OVERLAY_MAX_WIDTH = 480
|
|
25
|
+
|
|
26
|
+
function clampWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): number {
|
|
27
|
+
return Math.min(max, Math.max(SECTION_INSPECTOR_MIN_WIDTH, width))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Max inline width given the surrounding editor row. */
|
|
31
|
+
export function computeInspectorMaxWidth(containerWidth: number): number {
|
|
32
|
+
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
|
|
33
|
+
return SECTION_INSPECTOR_MAX_WIDTH
|
|
34
|
+
}
|
|
35
|
+
const containerCap = Math.max(
|
|
36
|
+
SECTION_INSPECTOR_MIN_WIDTH,
|
|
37
|
+
containerWidth - SECTION_INSPECTOR_MIN_CANVAS_WIDTH,
|
|
38
|
+
)
|
|
39
|
+
return Math.min(SECTION_INSPECTOR_MAX_WIDTH, containerCap)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Width when expanded as an overlay on tablet/mobile. */
|
|
43
|
+
export function computeInspectorOverlayWidth(containerWidth: number): number {
|
|
44
|
+
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
|
|
45
|
+
return SECTION_INSPECTOR_OVERLAY_MAX_WIDTH
|
|
46
|
+
}
|
|
47
|
+
return Math.min(
|
|
48
|
+
SECTION_INSPECTOR_OVERLAY_MAX_WIDTH,
|
|
49
|
+
Math.max(SECTION_INSPECTOR_MIN_WIDTH, containerWidth - 32),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readSectionInspectorWidth(): number {
|
|
54
|
+
if (typeof window === 'undefined') return SECTION_INSPECTOR_DEFAULT_WIDTH
|
|
55
|
+
try {
|
|
56
|
+
const raw = localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)
|
|
57
|
+
if (!raw) return SECTION_INSPECTOR_DEFAULT_WIDTH
|
|
58
|
+
const parsed = Number.parseInt(raw, 10)
|
|
59
|
+
if (!Number.isFinite(parsed)) return SECTION_INSPECTOR_DEFAULT_WIDTH
|
|
60
|
+
return clampWidth(parsed)
|
|
61
|
+
} catch {
|
|
62
|
+
return SECTION_INSPECTOR_DEFAULT_WIDTH
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function persistSectionInspectorWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): void {
|
|
67
|
+
try {
|
|
68
|
+
localStorage.setItem(SECTION_INSPECTOR_WIDTH_KEY, String(clampWidth(width, max)))
|
|
69
|
+
} catch {
|
|
70
|
+
// Storage may be unavailable in private mode.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface InspectorResizeContextValue {
|
|
75
|
+
width: number
|
|
76
|
+
maxWidth: number
|
|
77
|
+
isExpanded: boolean
|
|
78
|
+
canExpand: boolean
|
|
79
|
+
toggleExpanded: () => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const InspectorResizeContext = createContext<InspectorResizeContextValue | null>(null)
|
|
83
|
+
|
|
84
|
+
export function useInspectorResize(): InspectorResizeContextValue | null {
|
|
85
|
+
return useContext(InspectorResizeContext)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ResizableAsideProps extends ComponentPropsWithoutRef<'aside'> {
|
|
89
|
+
children: ReactNode
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Right-hand editor shell with a draggable left edge. Used by the page/post
|
|
94
|
+
* section inspector and post header panel (default 320px, persisted in
|
|
95
|
+
* localStorage). On tablet/mobile the handle uses a 44px touch target and
|
|
96
|
+
* the header expand button can grow the panel to an overlay width.
|
|
97
|
+
*/
|
|
98
|
+
export function ResizableAside({ children, className, style, ...props }: ResizableAsideProps) {
|
|
99
|
+
const asideRef = useRef<HTMLElement>(null)
|
|
100
|
+
const [width, setWidth] = useState(SECTION_INSPECTOR_DEFAULT_WIDTH)
|
|
101
|
+
const [containerWidth, setContainerWidth] = useState(0)
|
|
102
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
103
|
+
const [dragging, setDragging] = useState(false)
|
|
104
|
+
const widthRef = useRef(width)
|
|
105
|
+
const compactWidthRef = useRef(SECTION_INSPECTOR_DEFAULT_WIDTH)
|
|
106
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
|
107
|
+
|
|
108
|
+
const inlineMax = computeInspectorMaxWidth(containerWidth)
|
|
109
|
+
const overlayMax = computeInspectorOverlayWidth(containerWidth)
|
|
110
|
+
const useOverlay =
|
|
111
|
+
containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT && isExpanded
|
|
112
|
+
const effectiveMax = useOverlay ? overlayMax : inlineMax
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setWidth(readSectionInspectorWidth())
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
widthRef.current = width
|
|
120
|
+
}, [width])
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const parent = asideRef.current?.parentElement
|
|
124
|
+
if (!parent) return
|
|
125
|
+
|
|
126
|
+
const update = (nextWidth: number) => {
|
|
127
|
+
setContainerWidth(nextWidth)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
update(parent.getBoundingClientRect().width)
|
|
131
|
+
const observer = new ResizeObserver((entries) => {
|
|
132
|
+
const entry = entries[0]
|
|
133
|
+
if (!entry) return
|
|
134
|
+
update(entry.contentRect.width)
|
|
135
|
+
})
|
|
136
|
+
observer.observe(parent)
|
|
137
|
+
return () => observer.disconnect()
|
|
138
|
+
}, [])
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setWidth((current) => clampWidth(current, effectiveMax))
|
|
142
|
+
}, [effectiveMax])
|
|
143
|
+
|
|
144
|
+
const endDrag = useCallback(() => {
|
|
145
|
+
dragRef.current = null
|
|
146
|
+
setDragging(false)
|
|
147
|
+
document.body.style.cursor = ''
|
|
148
|
+
document.body.style.userSelect = ''
|
|
149
|
+
persistSectionInspectorWidth(widthRef.current, inlineMax)
|
|
150
|
+
if (widthRef.current < effectiveMax - 24) {
|
|
151
|
+
setIsExpanded(false)
|
|
152
|
+
}
|
|
153
|
+
}, [effectiveMax, inlineMax])
|
|
154
|
+
|
|
155
|
+
const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
156
|
+
if (event.button !== 0) return
|
|
157
|
+
dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
|
|
158
|
+
setDragging(true)
|
|
159
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
160
|
+
document.body.style.cursor = 'col-resize'
|
|
161
|
+
document.body.style.userSelect = 'none'
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
const onPointerMove = useCallback(
|
|
165
|
+
(event: PointerEvent<HTMLDivElement>) => {
|
|
166
|
+
if (!dragRef.current) return
|
|
167
|
+
const delta = dragRef.current.startX - event.clientX
|
|
168
|
+
setWidth(clampWidth(dragRef.current.startWidth + delta, effectiveMax))
|
|
169
|
+
},
|
|
170
|
+
[effectiveMax],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const onPointerUp = useCallback(
|
|
174
|
+
(event: PointerEvent<HTMLDivElement>) => {
|
|
175
|
+
if (!dragRef.current) return
|
|
176
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
177
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
178
|
+
}
|
|
179
|
+
endDrag()
|
|
180
|
+
},
|
|
181
|
+
[endDrag],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const nudgeWidth = useCallback(
|
|
185
|
+
(delta: number) => {
|
|
186
|
+
setWidth((current) => {
|
|
187
|
+
const next = clampWidth(current + delta, effectiveMax)
|
|
188
|
+
persistSectionInspectorWidth(next, inlineMax)
|
|
189
|
+
if (next < effectiveMax - 24) setIsExpanded(false)
|
|
190
|
+
return next
|
|
191
|
+
})
|
|
192
|
+
},
|
|
193
|
+
[effectiveMax, inlineMax],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const onKeyDown = useCallback(
|
|
197
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
198
|
+
if (event.key === 'ArrowLeft') {
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
nudgeWidth(16)
|
|
201
|
+
} else if (event.key === 'ArrowRight') {
|
|
202
|
+
event.preventDefault()
|
|
203
|
+
nudgeWidth(-16)
|
|
204
|
+
} else if (event.key === 'Home') {
|
|
205
|
+
event.preventDefault()
|
|
206
|
+
setIsExpanded(false)
|
|
207
|
+
setWidth(SECTION_INSPECTOR_MIN_WIDTH)
|
|
208
|
+
persistSectionInspectorWidth(SECTION_INSPECTOR_MIN_WIDTH, inlineMax)
|
|
209
|
+
} else if (event.key === 'End') {
|
|
210
|
+
event.preventDefault()
|
|
211
|
+
setWidth(effectiveMax)
|
|
212
|
+
persistSectionInspectorWidth(effectiveMax, inlineMax)
|
|
213
|
+
setIsExpanded(effectiveMax >= inlineMax - 8)
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
[effectiveMax, inlineMax, nudgeWidth],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const toggleExpanded = useCallback(() => {
|
|
220
|
+
if (isExpanded) {
|
|
221
|
+
const restored = clampWidth(compactWidthRef.current, inlineMax)
|
|
222
|
+
setIsExpanded(false)
|
|
223
|
+
setWidth(restored)
|
|
224
|
+
persistSectionInspectorWidth(restored, inlineMax)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
compactWidthRef.current = widthRef.current
|
|
229
|
+
const target =
|
|
230
|
+
containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
|
|
231
|
+
? overlayMax
|
|
232
|
+
: inlineMax
|
|
233
|
+
setIsExpanded(true)
|
|
234
|
+
setWidth(target)
|
|
235
|
+
persistSectionInspectorWidth(target, inlineMax)
|
|
236
|
+
}, [containerWidth, inlineMax, isExpanded, overlayMax])
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
return () => {
|
|
240
|
+
if (dragRef.current) endDrag()
|
|
241
|
+
}
|
|
242
|
+
}, [endDrag])
|
|
243
|
+
|
|
244
|
+
const canExpand =
|
|
245
|
+
!isExpanded &&
|
|
246
|
+
(containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
|
|
247
|
+
? overlayMax > width + 8
|
|
248
|
+
: inlineMax > width + 8)
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<InspectorResizeContext.Provider
|
|
252
|
+
value={{ width, maxWidth: effectiveMax, isExpanded, canExpand, toggleExpanded }}
|
|
253
|
+
>
|
|
254
|
+
<aside
|
|
255
|
+
{...props}
|
|
256
|
+
ref={asideRef}
|
|
257
|
+
data-overlay={useOverlay ? '' : undefined}
|
|
258
|
+
className={`relative shrink-0 ${useOverlay ? 'absolute top-0 right-0 bottom-0 z-20 shadow-lg' : ''} ${className ?? ''}`}
|
|
259
|
+
style={{ width, ...style }}
|
|
260
|
+
>
|
|
261
|
+
<div
|
|
262
|
+
role="separator"
|
|
263
|
+
aria-orientation="vertical"
|
|
264
|
+
aria-label="Resize inspector panel"
|
|
265
|
+
aria-valuemin={SECTION_INSPECTOR_MIN_WIDTH}
|
|
266
|
+
aria-valuemax={effectiveMax}
|
|
267
|
+
aria-valuenow={width}
|
|
268
|
+
tabIndex={0}
|
|
269
|
+
data-dragging={dragging ? '' : undefined}
|
|
270
|
+
onPointerDown={onPointerDown}
|
|
271
|
+
onPointerMove={onPointerMove}
|
|
272
|
+
onPointerUp={onPointerUp}
|
|
273
|
+
onPointerCancel={onPointerUp}
|
|
274
|
+
onKeyDown={onKeyDown}
|
|
275
|
+
className="group absolute inset-y-0 -left-5 z-10 flex w-11 -translate-x-1/2 cursor-col-resize touch-none focus-visible:outline-none md:-left-1 md:w-3 md:translate-x-0"
|
|
276
|
+
>
|
|
277
|
+
<div
|
|
278
|
+
className={`mx-auto h-full w-px motion-safe:transition-colors md:mx-0 ${
|
|
279
|
+
dragging
|
|
280
|
+
? 'bg-primary'
|
|
281
|
+
: 'bg-border group-hover:bg-primary/50 group-focus-visible:bg-primary/50'
|
|
282
|
+
}`}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
{children}
|
|
286
|
+
</aside>
|
|
287
|
+
</InspectorResizeContext.Provider>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const EXPAND_BUTTON_CLASS =
|
|
292
|
+
'text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none md:h-7 md:w-7'
|
|
293
|
+
|
|
294
|
+
/** One-tap expand/collapse for tablet and mobile inspector panels. */
|
|
295
|
+
export function InspectorExpandButton({ className }: { className?: string }) {
|
|
296
|
+
const ctx = useInspectorResize()
|
|
297
|
+
if (!ctx) return null
|
|
298
|
+
|
|
299
|
+
const { isExpanded, canExpand, toggleExpanded } = ctx
|
|
300
|
+
if (!canExpand && !isExpanded) return null
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={toggleExpanded}
|
|
306
|
+
aria-label={isExpanded ? 'Collapse inspector panel' : 'Expand inspector panel'}
|
|
307
|
+
aria-pressed={isExpanded}
|
|
308
|
+
className={className ?? EXPAND_BUTTON_CLASS}
|
|
309
|
+
>
|
|
310
|
+
{isExpanded ? (
|
|
311
|
+
<Minimize2 className="h-4 w-4" aria-hidden />
|
|
312
|
+
) : (
|
|
313
|
+
<Maximize2 className="h-4 w-4" aria-hidden />
|
|
314
|
+
)}
|
|
315
|
+
</button>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
@@ -4,7 +4,7 @@ import * as Switch from '@radix-ui/react-switch'
|
|
|
4
4
|
import { cv, type VariantProps } from '../../lib/cv.js'
|
|
5
5
|
|
|
6
6
|
const toggleTrack = cv(
|
|
7
|
-
'relative inline-
|
|
7
|
+
'relative inline-block shrink-0 cursor-pointer rounded-[var(--r-pill)] border-0 p-0 transition-colors duration-[var(--motion-base)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
@@ -26,11 +26,12 @@ const toggleTrack = cv(
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
const toggleThumb = cv(
|
|
29
|
-
'pointer-events-none block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-
|
|
29
|
+
'pointer-events-none absolute top-[2.5px] left-[2.5px] block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-[left] duration-[var(--motion-base)] data-[state=checked]:left-[19.5px]',
|
|
30
30
|
{
|
|
31
31
|
variants: {
|
|
32
32
|
size: {
|
|
33
|
-
|
|
33
|
+
/** Matches `.tog-thumb` in the design system handoff (16×16). */
|
|
34
|
+
md: 'h-4 w-4',
|
|
34
35
|
},
|
|
35
36
|
},
|
|
36
37
|
defaultVariants: {
|