@actuate-media/cms-admin 0.20.0 → 0.21.1
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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +28 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +60 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/seo/Drawer.d.ts.map +1 -1
- package/dist/components/seo/Drawer.js +5 -1
- package/dist/components/seo/Drawer.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +11 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +2 -1
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +105 -12
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +40 -4
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +2 -1
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/AuditTab.js +2 -2
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/ContentTab.js +1 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +8 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +7 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/seo/useSeoResource.d.ts.map +1 -1
- package/dist/views/seo/useSeoResource.js +20 -7
- package/dist/views/seo/useSeoResource.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +26 -4
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +86 -0
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +4 -0
- package/src/components/seo/SeoEditorDrawer.tsx +15 -3
- package/src/components/seo/primitives.tsx +4 -1
- package/src/layout/Sidebar.tsx +228 -19
- package/src/lib/seo-service.ts +41 -4
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- package/src/views/Forms.tsx +3 -1
- package/src/views/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- package/src/views/seo/AuditTab.tsx +6 -6
- package/src/views/seo/ContentTab.tsx +3 -3
- package/src/views/seo/RedirectsTab.tsx +8 -2
- package/src/views/seo/TechnicalTab.tsx +25 -13
- package/src/views/seo/useSeoResource.ts +21 -7
package/src/AdminRoot.tsx
CHANGED
|
@@ -13,8 +13,6 @@ import { Settings } from './views/Settings.js'
|
|
|
13
13
|
import { Forms } from './views/Forms.js'
|
|
14
14
|
import { FormEditor } from './views/FormEditor.js'
|
|
15
15
|
import { FormSubmissions } from './views/FormSubmissions.js'
|
|
16
|
-
import { Users } from './views/Users.js'
|
|
17
|
-
import { ApiKeys } from './views/ApiKeys.js'
|
|
18
16
|
import { SEO } from './views/SEO.js'
|
|
19
17
|
import { ScriptTags } from './views/ScriptTags.js'
|
|
20
18
|
import { ScriptTagEditor } from './views/ScriptTagEditor.js'
|
|
@@ -419,12 +417,14 @@ function AdminShell({
|
|
|
419
417
|
return <Widgets onNavigate={navigate} />
|
|
420
418
|
}
|
|
421
419
|
|
|
420
|
+
// Users + API Keys moved into the Settings view as tabs. The legacy
|
|
421
|
+
// top-level routes still resolve — they just land on the matching tab.
|
|
422
422
|
if (matchRoute('/users')) {
|
|
423
|
-
return <
|
|
423
|
+
return <Settings onNavigate={navigate} config={config} initialTab="users" />
|
|
424
424
|
}
|
|
425
425
|
|
|
426
426
|
if (matchRoute('/api-keys')) {
|
|
427
|
-
return <
|
|
427
|
+
return <Settings onNavigate={navigate} config={config} initialTab="api-keys" />
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
if (matchRoute('/settings')) {
|
|
@@ -702,6 +702,28 @@ const ISOLATION_STYLE: React.CSSProperties = {
|
|
|
702
702
|
export function AdminRoot(props: AdminRootProps) {
|
|
703
703
|
const defaultDarkMode = props.config?.admin?.branding?.darkMode
|
|
704
704
|
|
|
705
|
+
// The admin is private application UI and must never be indexed. Ensure a
|
|
706
|
+
// `noindex, nofollow` robots meta tag is present while the admin is mounted,
|
|
707
|
+
// as defense-in-depth alongside the server `robots.txt` Disallow rule. This
|
|
708
|
+
// covers every consumer install (the admin page is typically a client
|
|
709
|
+
// component that can't export Next.js route metadata).
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
if (typeof document === 'undefined') return
|
|
712
|
+
const PREV = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
|
|
713
|
+
const prevContent = PREV?.getAttribute('content') ?? null
|
|
714
|
+
const meta = PREV ?? document.createElement('meta')
|
|
715
|
+
meta.setAttribute('name', 'robots')
|
|
716
|
+
meta.setAttribute('content', 'noindex, nofollow')
|
|
717
|
+
if (!PREV) document.head.appendChild(meta)
|
|
718
|
+
return () => {
|
|
719
|
+
if (prevContent === null) {
|
|
720
|
+
meta.remove()
|
|
721
|
+
} else {
|
|
722
|
+
meta.setAttribute('content', prevContent)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}, [])
|
|
726
|
+
|
|
705
727
|
return (
|
|
706
728
|
<div style={ISOLATION_STYLE} className="actuate-admin">
|
|
707
729
|
<ThemeProvider defaultDarkMode={defaultDarkMode}>
|
|
@@ -156,9 +156,9 @@ describe('buildNavItems', () => {
|
|
|
156
156
|
// Lands after the static Content destinations, before Settings.
|
|
157
157
|
const menusIdx = items.findIndex((i) => i.path === '/navigations')
|
|
158
158
|
const scriptIdx = items.findIndex((i) => i.path === '/script-tags')
|
|
159
|
-
const
|
|
159
|
+
const settingsIdx = items.findIndex((i) => i.path === '/settings')
|
|
160
160
|
expect(menusIdx).toBeGreaterThan(scriptIdx)
|
|
161
|
-
expect(menusIdx).toBeLessThan(
|
|
161
|
+
expect(menusIdx).toBeLessThan(settingsIdx)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
164
|
it('keeps standard Content/Settings entries after collections', () => {
|
|
@@ -172,8 +172,10 @@ describe('buildNavItems', () => {
|
|
|
172
172
|
expect(paths).toContain('/forms')
|
|
173
173
|
expect(paths).toContain('/seo')
|
|
174
174
|
expect(paths).toContain('/script-tags')
|
|
175
|
-
expect(paths).toContain('/users')
|
|
176
|
-
expect(paths).toContain('/api-keys')
|
|
177
175
|
expect(paths).toContain('/settings')
|
|
176
|
+
// Users + API Keys moved into Settings tabs — they are no longer
|
|
177
|
+
// standalone sidebar destinations.
|
|
178
|
+
expect(paths).not.toContain('/users')
|
|
179
|
+
expect(paths).not.toContain('/api-keys')
|
|
178
180
|
})
|
|
179
181
|
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { Sidebar } from '../../layout/Sidebar.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Submenu rendering behaviour:
|
|
9
|
+
* - Expanded: parent children (e.g. Posts -> All Posts / Post Types) render
|
|
10
|
+
* inline beneath the parent.
|
|
11
|
+
* - Collapsed: children are hidden inline and instead revealed in a
|
|
12
|
+
* hover/focus flyout, so the 80px rail stays clean but sub-pages remain
|
|
13
|
+
* reachable.
|
|
14
|
+
*/
|
|
15
|
+
function renderSidebar(collapsed: boolean) {
|
|
16
|
+
return render(
|
|
17
|
+
<Sidebar
|
|
18
|
+
collapsed={collapsed}
|
|
19
|
+
onToggleCollapse={() => {}}
|
|
20
|
+
currentPath="/"
|
|
21
|
+
onNavigate={() => {}}
|
|
22
|
+
/>,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('Sidebar submenus', () => {
|
|
27
|
+
it('renders children inline when expanded', () => {
|
|
28
|
+
renderSidebar(false)
|
|
29
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
30
|
+
expect(screen.getByText('Post Types')).toBeTruthy()
|
|
31
|
+
expect(screen.getByText('Saved Sections')).toBeTruthy()
|
|
32
|
+
expect(screen.getByText('Widgets')).toBeTruthy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('collapses and re-expands a parent submenu via the chevron toggle', () => {
|
|
36
|
+
renderSidebar(false)
|
|
37
|
+
|
|
38
|
+
// Children visible by default.
|
|
39
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
40
|
+
|
|
41
|
+
// Collapse the Posts section.
|
|
42
|
+
fireEvent.click(screen.getByRole('button', { name: 'Collapse Posts' }))
|
|
43
|
+
expect(screen.queryByText('All Posts')).toBeNull()
|
|
44
|
+
|
|
45
|
+
// Re-expand it.
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand Posts' }))
|
|
47
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps the active section expanded even when toggled closed', () => {
|
|
51
|
+
render(
|
|
52
|
+
<Sidebar
|
|
53
|
+
collapsed={false}
|
|
54
|
+
onToggleCollapse={() => {}}
|
|
55
|
+
currentPath="/posts"
|
|
56
|
+
onNavigate={() => {}}
|
|
57
|
+
/>,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Active section forces the submenu open; the chevron reports it as
|
|
61
|
+
// expanded and offers to collapse.
|
|
62
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: 'Collapse Posts' }))
|
|
64
|
+
// Still visible because the section is the active route.
|
|
65
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('hides children inline when collapsed and exposes them in a hover flyout', async () => {
|
|
69
|
+
renderSidebar(true)
|
|
70
|
+
|
|
71
|
+
// Inline children are not rendered in the collapsed rail.
|
|
72
|
+
expect(screen.queryByText('All Posts')).toBeNull()
|
|
73
|
+
expect(screen.queryByText('Post Types')).toBeNull()
|
|
74
|
+
|
|
75
|
+
// The parent is still reachable as an icon button labelled for AT.
|
|
76
|
+
const trigger = screen.getByRole('button', { name: 'Posts' })
|
|
77
|
+
|
|
78
|
+
// Hovering the parent opens the flyout (onMouseEnter lives on the wrapper).
|
|
79
|
+
fireEvent.mouseEnter(trigger.parentElement as HTMLElement)
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(screen.getByText('All Posts')).toBeTruthy()
|
|
83
|
+
expect(screen.getByText('Post Types')).toBeTruthy()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const cmsApi = vi.fn()
|
|
5
|
+
vi.mock('../../lib/api.js', () => ({
|
|
6
|
+
cmsApi: (...args: unknown[]) => cmsApi(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
fetchSeoOverview,
|
|
11
|
+
fetchSeoContentRecords,
|
|
12
|
+
fetchSeoIssues,
|
|
13
|
+
fetchRedirectRules,
|
|
14
|
+
updateSeoFields,
|
|
15
|
+
} = await import('../../lib/seo-service.js')
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cmsApi.mockReset()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Regression tests for the "swallowed read errors" blocker. `cmsApi` never
|
|
23
|
+
* throws — it returns `{ error }` on failure — so the SEO read fetchers must
|
|
24
|
+
* re-throw, otherwise `useSeoResource` can't distinguish "request failed" from
|
|
25
|
+
* "empty result" and renders a misleading fallback (e.g. a fake score of 0 /
|
|
26
|
+
* grade "Critical") with the error/retry UI permanently unreachable.
|
|
27
|
+
*/
|
|
28
|
+
describe('SEO service read fetchers surface errors', () => {
|
|
29
|
+
it('fetchSeoOverview throws when the request fails (instead of returning a fake 0 score)', async () => {
|
|
30
|
+
cmsApi.mockResolvedValue({ error: 'Request failed (500)', status: 500 })
|
|
31
|
+
await expect(fetchSeoOverview()).rejects.toThrow('Request failed (500)')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('fetchSeoContentRecords throws on error', async () => {
|
|
35
|
+
cmsApi.mockResolvedValue({ error: 'Network error', status: 0 })
|
|
36
|
+
await expect(fetchSeoContentRecords()).rejects.toThrow('Network error')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('fetchSeoIssues throws on error', async () => {
|
|
40
|
+
cmsApi.mockResolvedValue({ error: 'Request failed (503)', status: 503 })
|
|
41
|
+
await expect(fetchSeoIssues()).rejects.toThrow('Request failed (503)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('fetchRedirectRules throws on error', async () => {
|
|
45
|
+
cmsApi.mockResolvedValue({ error: 'boom', status: 500 })
|
|
46
|
+
await expect(fetchRedirectRules()).rejects.toThrow('boom')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns data normally on success', async () => {
|
|
50
|
+
cmsApi.mockResolvedValue({ data: { records: [{ id: 'r1' }] }, status: 200 })
|
|
51
|
+
await expect(fetchSeoContentRecords()).resolves.toEqual([{ id: 'r1' }])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('mutations still return { error } rather than throwing', async () => {
|
|
55
|
+
cmsApi.mockResolvedValue({ error: 'forbidden', status: 403 })
|
|
56
|
+
await expect(updateSeoFields('page', 'doc-1', { metaTitle: 'x' })).resolves.toEqual({
|
|
57
|
+
error: 'forbidden',
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { useSeoResource } from '../../views/seo/useSeoResource.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression test for the `useSeoResource` stale-response race: when the
|
|
9
|
+
* dependency (and therefore the fetcher) changes rapidly, a slow earlier
|
|
10
|
+
* request that resolves AFTER a faster later one must not clobber the newer
|
|
11
|
+
* result. We drive this with two deferred promises whose resolution order is
|
|
12
|
+
* inverted relative to the request order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function Probe({ dep, fetcher }: { dep: number; fetcher: () => Promise<string> }) {
|
|
16
|
+
const { data, loading, error } = useSeoResource<string>(fetcher, [dep])
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
<span data-testid="state">{loading ? 'loading' : (error ?? data ?? 'empty')}</span>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function deferred<T>() {
|
|
25
|
+
let resolve!: (value: T) => void
|
|
26
|
+
let reject!: (reason?: unknown) => void
|
|
27
|
+
const promise = new Promise<T>((res, rej) => {
|
|
28
|
+
resolve = res
|
|
29
|
+
reject = rej
|
|
30
|
+
})
|
|
31
|
+
return { promise, resolve, reject }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useSeoResource', () => {
|
|
35
|
+
it('ignores a stale (earlier) response that resolves after the latest one', async () => {
|
|
36
|
+
const first = deferred<string>()
|
|
37
|
+
const second = deferred<string>()
|
|
38
|
+
|
|
39
|
+
// First render uses the "first" fetcher (slow), then we switch deps so the
|
|
40
|
+
// hook re-runs with the "second" fetcher (fast).
|
|
41
|
+
const firstFetcher = () => first.promise
|
|
42
|
+
const secondFetcher = () => second.promise
|
|
43
|
+
|
|
44
|
+
const { rerender } = render(<Probe dep={1} fetcher={firstFetcher} />)
|
|
45
|
+
rerender(<Probe dep={2} fetcher={secondFetcher} />)
|
|
46
|
+
|
|
47
|
+
// The newer request resolves FIRST.
|
|
48
|
+
await act(async () => {
|
|
49
|
+
second.resolve('NEW')
|
|
50
|
+
})
|
|
51
|
+
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('NEW'))
|
|
52
|
+
|
|
53
|
+
// The stale earlier request resolves LATER — it must be ignored.
|
|
54
|
+
await act(async () => {
|
|
55
|
+
first.resolve('OLD')
|
|
56
|
+
})
|
|
57
|
+
expect(screen.getByTestId('state').textContent).toBe('NEW')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('surfaces fetcher errors', async () => {
|
|
61
|
+
const d = deferred<string>()
|
|
62
|
+
render(<Probe dep={1} fetcher={() => d.promise} />)
|
|
63
|
+
await act(async () => {
|
|
64
|
+
d.reject(new Error('boom'))
|
|
65
|
+
})
|
|
66
|
+
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('boom'))
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -36,7 +36,10 @@ export interface BreadcrumbsProps {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
|
|
39
|
-
|
|
39
|
+
// Strip query string + hash so tab-driven routes (e.g. `/settings?tab=users`,
|
|
40
|
+
// `/seo?tab=audit`) don't leak the raw query into a crumb label.
|
|
41
|
+
const cleanPath = currentPath.split('?')[0]!.split('#')[0]!
|
|
42
|
+
const segments = cleanPath.split('/').filter(Boolean)
|
|
40
43
|
|
|
41
44
|
if (segments.length === 0) return null
|
|
42
45
|
|
|
@@ -31,6 +31,10 @@ export function Drawer({
|
|
|
31
31
|
<Dialog.Portal>
|
|
32
32
|
<Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
|
|
33
33
|
<Dialog.Content
|
|
34
|
+
// Radix auto-associates `aria-describedby` with the Dialog.Description
|
|
35
|
+
// when one is present; when it isn't, explicitly clear the attribute so
|
|
36
|
+
// Radix doesn't emit its "Missing Description" console warning.
|
|
37
|
+
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
34
38
|
className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
|
|
35
39
|
>
|
|
36
40
|
<header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
3
|
+
import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
|
|
4
4
|
import { Sparkles, Check } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import {
|
|
@@ -36,13 +36,25 @@ function Field({
|
|
|
36
36
|
children: React.ReactNode
|
|
37
37
|
hint?: React.ReactNode
|
|
38
38
|
}) {
|
|
39
|
+
// Associate the visible label with its control for screen readers. We inject
|
|
40
|
+
// a generated id onto the single child input/textarea (unless it already has
|
|
41
|
+
// one) and point the <label htmlFor> at it.
|
|
42
|
+
const generatedId = useId()
|
|
43
|
+
let control = children
|
|
44
|
+
if (isValidElement(children) && (children.props as { id?: string }).id == null) {
|
|
45
|
+
control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
|
|
46
|
+
}
|
|
47
|
+
const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
|
|
48
|
+
|
|
39
49
|
return (
|
|
40
50
|
<div className="space-y-1.5">
|
|
41
51
|
<div className="flex items-center justify-between">
|
|
42
|
-
<label className="text-foreground text-sm font-medium">
|
|
52
|
+
<label htmlFor={controlId} className="text-foreground text-sm font-medium">
|
|
53
|
+
{label}
|
|
54
|
+
</label>
|
|
43
55
|
{hint}
|
|
44
56
|
</div>
|
|
45
|
-
{
|
|
57
|
+
{control}
|
|
46
58
|
</div>
|
|
47
59
|
)
|
|
48
60
|
}
|
|
@@ -112,13 +112,16 @@ export function SeoStatusBadge({
|
|
|
112
112
|
export function SeoScoreBar({ score, className = '' }: { score: number; className?: string }) {
|
|
113
113
|
const grade = gradeFromScore(score)
|
|
114
114
|
const fill = grade === 'good' ? 'bg-success' : grade === 'fair' ? 'bg-warning' : 'bg-destructive'
|
|
115
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)))
|
|
115
116
|
return (
|
|
116
117
|
<div
|
|
117
118
|
className={`bg-muted h-2 w-full overflow-hidden rounded-full ${className}`}
|
|
118
119
|
role="progressbar"
|
|
119
|
-
aria-
|
|
120
|
+
aria-label="SEO score"
|
|
121
|
+
aria-valuenow={clamped}
|
|
120
122
|
aria-valuemin={0}
|
|
121
123
|
aria-valuemax={100}
|
|
124
|
+
aria-valuetext={`${clamped} out of 100`}
|
|
122
125
|
>
|
|
123
126
|
<div
|
|
124
127
|
className={`h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none ${fill}`}
|