@uniweb/kit 0.9.33 → 0.9.35
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/package.json +5 -4
- package/src/hooks/index.js +1 -0
- package/src/hooks/useHeadings.js +218 -0
- package/src/index.js +5 -1
- package/src/prose-tokens.css +73 -0
- package/src/styled/Prose/index.jsx +2 -9
- package/src/styled/Section/Render.jsx +2 -11
- package/src/styled/Section/renderers/Code.jsx +114 -66
- package/src/styled/index.js +1 -2
- package/src/utils/index.js +34 -0
- package/src/styled/SidebarLayout/SidebarLayout.jsx +0 -310
- package/src/styled/SidebarLayout/index.js +0 -1
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.35",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./xref": "./src/xref/index.js",
|
|
9
|
-
"./theme-tokens.css": "./src/theme-tokens.css"
|
|
9
|
+
"./theme-tokens.css": "./src/theme-tokens.css",
|
|
10
|
+
"./prose-tokens.css": "./src/prose-tokens.css"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"src",
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
"fuse.js": "^7.0.0",
|
|
40
41
|
"shiki": "^3.0.0",
|
|
41
42
|
"tailwind-merge": "^3.6.0",
|
|
42
|
-
"@uniweb/
|
|
43
|
-
"@uniweb/
|
|
43
|
+
"@uniweb/scene": "0.1.2",
|
|
44
|
+
"@uniweb/core": "0.7.27"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"react": "^19.0.0",
|
package/src/hooks/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { useVersion } from './useVersion.js'
|
|
|
9
9
|
export { useScrolled } from './useScrolled.js'
|
|
10
10
|
export { useMobileMenu } from './useMobileMenu.js'
|
|
11
11
|
export { useAccordion } from './useAccordion.js'
|
|
12
|
+
export { useHeadings } from './useHeadings.js'
|
|
12
13
|
export { useGridLayout, getGridClasses } from './useGridLayout.js'
|
|
13
14
|
export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
|
|
14
15
|
export { useInView, useIsInView } from './useInView.js'
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHeadings Hook
|
|
3
|
+
*
|
|
4
|
+
* The headings of the page being read, plus which one the reader is level with.
|
|
5
|
+
* What a table of contents needs, without any opinion about how it looks.
|
|
6
|
+
*
|
|
7
|
+
* Every documentation shell on this framework has hand-written this, and each
|
|
8
|
+
* one wrote it the only way a foundation can: scan the DOM for `h2, h3` after
|
|
9
|
+
* the body renders. That works, but it costs a frame of timing guesswork and it
|
|
10
|
+
* cannot run during prerender, so the rail is missing from every served page
|
|
11
|
+
* until hydration.
|
|
12
|
+
*
|
|
13
|
+
* The framework can do better because it owns both ends. It renders the
|
|
14
|
+
* headings and stamps their ids (`headingId`), and it holds the page content
|
|
15
|
+
* that produced them — so the list can come from the content, before anything
|
|
16
|
+
* is painted, and be guaranteed to match the anchors. Only the highlight needs
|
|
17
|
+
* the DOM, because only scrolling does.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* function PageContents() {
|
|
21
|
+
* const { headings, activeId, scrollTo } = useHeadings()
|
|
22
|
+
* if (!headings.length) return null
|
|
23
|
+
*
|
|
24
|
+
* return (
|
|
25
|
+
* <nav aria-label="On this page">
|
|
26
|
+
* {headings.map(h => (
|
|
27
|
+
* <button key={h.id} onClick={() => scrollTo(h.id)}
|
|
28
|
+
* className={h.id === activeId ? 'text-primary' : 'text-subtle'}>
|
|
29
|
+
* {h.text}
|
|
30
|
+
* </button>
|
|
31
|
+
* ))}
|
|
32
|
+
* </nav>
|
|
33
|
+
* )
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
38
|
+
import { headingId, nodeText } from '../utils/index.js'
|
|
39
|
+
import { useWebsite } from './useWebsite.js'
|
|
40
|
+
import { useActiveRoute } from './useActiveRoute.js'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pull headings out of the page's own content — available during prerender.
|
|
44
|
+
*
|
|
45
|
+
* Skips the headings the semantic parser claimed as the section's own
|
|
46
|
+
* title/pretitle/subtitle. In this framework a leading `###` is a pretitle and
|
|
47
|
+
* the `##` after the title is a subtitle — structure, not body headings — so a
|
|
48
|
+
* typed section would otherwise open every contents rail with two entries no
|
|
49
|
+
* reader recognises as sections of the article. An untyped document has none of
|
|
50
|
+
* those fields set, and everything in it counts.
|
|
51
|
+
*/
|
|
52
|
+
export function headingsFromContent(page, levels = [2, 3]) {
|
|
53
|
+
const blocks = page?.getBodyBlocks?.() ?? []
|
|
54
|
+
const found = []
|
|
55
|
+
|
|
56
|
+
for (const block of blocks) {
|
|
57
|
+
const nodes = block?.rawContent?.content
|
|
58
|
+
if (!Array.isArray(nodes)) continue
|
|
59
|
+
|
|
60
|
+
const parsed = block.parsedContent || {}
|
|
61
|
+
const claimed = new Set(
|
|
62
|
+
[parsed.title, parsed.pretitle, parsed.subtitle]
|
|
63
|
+
.flat()
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map(value => String(value).trim())
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for (const node of nodes) {
|
|
69
|
+
if (node?.type !== 'heading') continue
|
|
70
|
+
const level = node.attrs?.level ?? 1
|
|
71
|
+
if (!levels.includes(level)) continue
|
|
72
|
+
|
|
73
|
+
const text = nodeText(node).trim()
|
|
74
|
+
if (!text || claimed.has(text)) continue
|
|
75
|
+
|
|
76
|
+
found.push({ id: headingId(text), text, level })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return found
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fall back to the rendered document, for content this hook cannot see — a
|
|
85
|
+
* foundation rendering its own markup, or anything not built from page blocks.
|
|
86
|
+
*/
|
|
87
|
+
function headingsFromDom(root, levels) {
|
|
88
|
+
if (typeof document === 'undefined') return []
|
|
89
|
+
|
|
90
|
+
const scope = document.querySelector(root)
|
|
91
|
+
if (!scope) return []
|
|
92
|
+
|
|
93
|
+
const selector = levels.map(level => `h${level}`).join(', ')
|
|
94
|
+
|
|
95
|
+
return [...scope.querySelectorAll(selector)]
|
|
96
|
+
.map(el => {
|
|
97
|
+
const text = el.textContent?.trim() || ''
|
|
98
|
+
if (!text) return null
|
|
99
|
+
// Adopt the id that is there; stamp the shared one when it is missing, so
|
|
100
|
+
// scrollTo has something to find either way.
|
|
101
|
+
if (!el.id) el.id = headingId(text)
|
|
102
|
+
return { id: el.id, text, level: Number(el.tagName[1]) }
|
|
103
|
+
})
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Nest a flat, ordered heading list by level. A heading deeper than the one
|
|
109
|
+
* before it becomes its child; anything at or above the top level starts a new
|
|
110
|
+
* branch. Headings that skip a level still land somewhere sensible.
|
|
111
|
+
*/
|
|
112
|
+
function nest(flat) {
|
|
113
|
+
if (!flat.length) return []
|
|
114
|
+
|
|
115
|
+
const topLevel = Math.min(...flat.map(h => h.level))
|
|
116
|
+
const tree = []
|
|
117
|
+
let current = null
|
|
118
|
+
|
|
119
|
+
for (const heading of flat) {
|
|
120
|
+
const node = { ...heading, children: [] }
|
|
121
|
+
if (heading.level === topLevel || !current) {
|
|
122
|
+
tree.push(node)
|
|
123
|
+
current = node
|
|
124
|
+
} else {
|
|
125
|
+
current.children.push(node)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return tree
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Height of the fixed site header, so anchors are not scrolled under it.
|
|
134
|
+
*/
|
|
135
|
+
function headerOffset(explicit) {
|
|
136
|
+
if (typeof explicit === 'number') return explicit
|
|
137
|
+
if (typeof document === 'undefined') return 0
|
|
138
|
+
|
|
139
|
+
const declared = getComputedStyle(document.documentElement).getPropertyValue('--header-height')
|
|
140
|
+
return (parseInt(declared, 10) || 0) + 16
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {Object} [options]
|
|
145
|
+
* @param {number[]} [options.levels=[2,3]] - Heading levels to collect
|
|
146
|
+
* @param {string} [options.root='main'] - Selector to scan, for the DOM fallback
|
|
147
|
+
* @param {number} [options.offset] - Scroll offset in px; defaults to the site
|
|
148
|
+
* header's height read from `--header-height`, plus a little breathing room
|
|
149
|
+
* @returns {{ headings: Array, activeId: string, scrollTo: (id: string) => void }}
|
|
150
|
+
* `headings` is nested — each entry carries `{ id, text, level, children }`
|
|
151
|
+
*/
|
|
152
|
+
export function useHeadings({ levels = [2, 3], root = 'main', offset } = {}) {
|
|
153
|
+
const { website } = useWebsite()
|
|
154
|
+
const { route } = useActiveRoute()
|
|
155
|
+
const [domHeadings, setDomHeadings] = useState(null)
|
|
156
|
+
const [activeId, setActiveId] = useState('')
|
|
157
|
+
|
|
158
|
+
// The content path runs during render, so it is available server-side.
|
|
159
|
+
const contentHeadings = useMemo(
|
|
160
|
+
() => headingsFromContent(website?.activePage, levels),
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
[website?.activePage, route, levels.join()]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// Only reached when the content path found nothing.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
setActiveId('')
|
|
168
|
+
if (contentHeadings.length) {
|
|
169
|
+
setDomHeadings(null)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const id = requestAnimationFrame(() => setDomHeadings(headingsFromDom(root, levels)))
|
|
173
|
+
return () => cancelAnimationFrame(id)
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
}, [route, contentHeadings.length, root, levels.join()])
|
|
176
|
+
|
|
177
|
+
const flat = contentHeadings.length ? contentHeadings : domHeadings ?? []
|
|
178
|
+
const headings = useMemo(() => nest(flat), [flat])
|
|
179
|
+
|
|
180
|
+
// Which heading the reader is level with. Scroll position is the one thing
|
|
181
|
+
// here that only the DOM knows.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!flat.length || typeof window === 'undefined') return
|
|
184
|
+
|
|
185
|
+
const gap = headerOffset(offset)
|
|
186
|
+
|
|
187
|
+
function onScroll() {
|
|
188
|
+
const line = window.scrollY + gap + 4
|
|
189
|
+
let current = ''
|
|
190
|
+
for (const { id } of flat) {
|
|
191
|
+
const el = document.getElementById(id)
|
|
192
|
+
if (el && el.getBoundingClientRect().top + window.scrollY <= line) current = id
|
|
193
|
+
}
|
|
194
|
+
setActiveId(current || flat[0].id)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
198
|
+
onScroll()
|
|
199
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
200
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
+
}, [flat.map(h => h.id).join(), offset])
|
|
202
|
+
|
|
203
|
+
const scrollTo = useCallback(
|
|
204
|
+
id => {
|
|
205
|
+
const el = typeof document !== 'undefined' && document.getElementById(id)
|
|
206
|
+
if (!el) return
|
|
207
|
+
window.scrollTo({
|
|
208
|
+
top: el.getBoundingClientRect().top + window.scrollY - headerOffset(offset),
|
|
209
|
+
behavior: 'smooth',
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
[offset]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return { headings, activeId, scrollTo }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default useHeadings
|
package/src/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
useScrolled,
|
|
63
63
|
useMobileMenu,
|
|
64
64
|
useAccordion,
|
|
65
|
+
useHeadings,
|
|
65
66
|
useGridLayout,
|
|
66
67
|
getGridClasses,
|
|
67
68
|
useTheme,
|
|
@@ -99,6 +100,10 @@ export {
|
|
|
99
100
|
parseIconRef,
|
|
100
101
|
// Content utilities
|
|
101
102
|
splitContent,
|
|
103
|
+
// Heading anchors — one generator, shared by the renderers that stamp them
|
|
104
|
+
// and by anything that links to one (see useHeadings).
|
|
105
|
+
headingId,
|
|
106
|
+
nodeText,
|
|
102
107
|
// Href resolution (for foundations rendering their own links or prose HTML)
|
|
103
108
|
applyBasePath,
|
|
104
109
|
resolveRoute,
|
|
@@ -119,7 +124,6 @@ export {
|
|
|
119
124
|
// Styled Components (Tailwind-based)
|
|
120
125
|
// ============================================================================
|
|
121
126
|
|
|
122
|
-
export { SidebarLayout } from './styled/SidebarLayout/index.js'
|
|
123
127
|
export { Section, Render } from './styled/Section/index.js'
|
|
124
128
|
export { Prose } from './styled/Prose/index.jsx'
|
|
125
129
|
export { Article } from './styled/Article/index.jsx'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind Typography, wired to the theme.
|
|
3
|
+
*
|
|
4
|
+
* Usage — after the plugin, so these win on equal specificity:
|
|
5
|
+
*
|
|
6
|
+
* @plugin "@tailwindcss/typography";
|
|
7
|
+
* @import "@uniweb/kit/prose-tokens.css";
|
|
8
|
+
*
|
|
9
|
+
* `prose` ships its own greys, which means long-form body copy is the one part
|
|
10
|
+
* of a foundation that ignores the site's theme.yml unless someone bridges it
|
|
11
|
+
* by hand. Everyone does bridge it by hand, identically, and the bridge is easy
|
|
12
|
+
* to get subtly wrong — so it lives here.
|
|
13
|
+
*
|
|
14
|
+
* After this import, a prose container answers to the site like everything else
|
|
15
|
+
* and flips with the visitor's scheme on its own. Do not add a `prose-invert`
|
|
16
|
+
* or a `dark:` variant: the tokens have already changed by the time the dark
|
|
17
|
+
* scheme is active, and `prose-invert` would override this with fixed greys.
|
|
18
|
+
*
|
|
19
|
+
* For the same reason, do not add a palette modifier — `prose-gray`,
|
|
20
|
+
* `prose-slate`, `prose-neutral`. Those re-declare every variable below.
|
|
21
|
+
*
|
|
22
|
+
* ── One container ──
|
|
23
|
+
*
|
|
24
|
+
* These variables are inherited, so a `prose` container nested inside another
|
|
25
|
+
* silently resets the lot for its subtree. Two containers is always a bug, and
|
|
26
|
+
* a quiet one: the outer looks right and everything inside it does not. Decide
|
|
27
|
+
* who owns prose — the section that renders the document is usually the better
|
|
28
|
+
* owner, since it then renders correctly under any layout — and make sure the
|
|
29
|
+
* other one only supplies column width and padding.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
.prose {
|
|
33
|
+
--tw-prose-body: var(--body);
|
|
34
|
+
--tw-prose-headings: var(--heading);
|
|
35
|
+
--tw-prose-lead: var(--subtle);
|
|
36
|
+
--tw-prose-links: var(--link, var(--primary));
|
|
37
|
+
--tw-prose-bold: var(--heading);
|
|
38
|
+
--tw-prose-counters: var(--subtle);
|
|
39
|
+
--tw-prose-bullets: var(--border);
|
|
40
|
+
--tw-prose-hr: var(--border);
|
|
41
|
+
--tw-prose-quotes: var(--subtle);
|
|
42
|
+
--tw-prose-quote-borders: var(--primary);
|
|
43
|
+
--tw-prose-captions: var(--subtle);
|
|
44
|
+
--tw-prose-kbd: var(--heading);
|
|
45
|
+
--tw-prose-code: var(--heading);
|
|
46
|
+
--tw-prose-th-borders: var(--border);
|
|
47
|
+
--tw-prose-td-borders: var(--border);
|
|
48
|
+
|
|
49
|
+
/* Fenced code keeps whatever the highlighter paints — a site retunes that
|
|
50
|
+
through `code:` in theme.yml, not here. */
|
|
51
|
+
--tw-prose-pre-code: var(--code-text, var(--body));
|
|
52
|
+
--tw-prose-pre-bg: var(--code-surface, var(--muted));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Inline code reads as a chip on whatever surface it sits on, rather than the
|
|
56
|
+
plugin's fixed grey. Scoped to `:not(pre)` so fenced blocks are untouched. */
|
|
57
|
+
.prose :not(pre) > code {
|
|
58
|
+
background-color: color-mix(in oklch, var(--section), var(--heading) 6%);
|
|
59
|
+
border-radius: 0.25rem;
|
|
60
|
+
padding: 0.125rem 0.375rem;
|
|
61
|
+
font-weight: 500;
|
|
62
|
+
font-size: 0.875em;
|
|
63
|
+
}
|
|
64
|
+
.prose :not(pre) > code::before,
|
|
65
|
+
.prose :not(pre) > code::after {
|
|
66
|
+
content: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Anchors land clear of a fixed site header when something scrolls to them —
|
|
70
|
+
a contents rail, or a link with a #fragment. */
|
|
71
|
+
.prose :is(h1, h2, h3, h4) {
|
|
72
|
+
scroll-margin-top: calc(var(--header-height, 4rem) + 1rem);
|
|
73
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React from 'react'
|
|
14
|
-
import { cn, getChildBlockRenderer } from '../../utils/index.js'
|
|
14
|
+
import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
|
|
15
15
|
import { SafeHtml } from '../../components/SafeHtml/index.js'
|
|
16
16
|
import { Image } from '../../components/Image/index.js'
|
|
17
17
|
import { Media } from '../../components/Media/index.js'
|
|
@@ -30,13 +30,6 @@ const SIZE_CLASSES = {
|
|
|
30
30
|
'2xl': 'prose-2xl'
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function generateId(text) {
|
|
34
|
-
return text
|
|
35
|
-
.replace(/<[^>]*>/g, '')
|
|
36
|
-
.toLowerCase()
|
|
37
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
38
|
-
.replace(/^-|-$/g, '')
|
|
39
|
-
}
|
|
40
33
|
|
|
41
34
|
const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
|
|
42
35
|
|
|
@@ -92,7 +85,7 @@ function SequenceElement({ element, block }) {
|
|
|
92
85
|
case 'heading': {
|
|
93
86
|
const level = Math.min(element.level || 1, 6)
|
|
94
87
|
const Tag = `h${level}`
|
|
95
|
-
const id =
|
|
88
|
+
const id = headingId(element.text || '')
|
|
96
89
|
return <Tag id={id}><SafeHtml value={element.text} as="span" /></Tag>
|
|
97
90
|
}
|
|
98
91
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react'
|
|
11
|
-
import { cn, getChildBlockRenderer } from '../../utils/index.js'
|
|
11
|
+
import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
|
|
12
12
|
import { SafeHtml } from '../../components/SafeHtml/index.js'
|
|
13
13
|
import { Image } from '../../components/Image/index.js'
|
|
14
14
|
import { Media } from '../../components/Media/index.js'
|
|
@@ -33,15 +33,6 @@ function extractText(node) {
|
|
|
33
33
|
return ''
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Generate ID from heading text
|
|
38
|
-
*/
|
|
39
|
-
function generateId(text) {
|
|
40
|
-
return text
|
|
41
|
-
.toLowerCase()
|
|
42
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
-
.replace(/^-|-$/g, '')
|
|
44
|
-
}
|
|
45
36
|
|
|
46
37
|
/**
|
|
47
38
|
* Render a list (ordered or unordered)
|
|
@@ -81,7 +72,7 @@ function RenderNode({ node, block, ...props }) {
|
|
|
81
72
|
case 'heading': {
|
|
82
73
|
const level = attrs?.level || 1
|
|
83
74
|
const text = extractText(node)
|
|
84
|
-
const id =
|
|
75
|
+
const id = headingId(text)
|
|
85
76
|
const Tag = `h${Math.min(level, 6)}`
|
|
86
77
|
|
|
87
78
|
return (
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Code Block Renderer
|
|
3
3
|
*
|
|
4
|
-
* Renders syntax-highlighted code blocks using Shiki
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Renders syntax-highlighted code blocks using Shiki, lazy-loaded only when a
|
|
5
|
+
* page actually has one. A site's `theme.yml` `code:` block becomes a Shiki
|
|
6
|
+
* theme layered over the default, so the colours a site declares are the
|
|
7
|
+
* colours Shiki writes.
|
|
7
8
|
*
|
|
8
9
|
* @module @uniweb/kit/Section/renderers/Code
|
|
9
10
|
*/
|
|
@@ -16,37 +17,90 @@ import { getUniweb } from '@uniweb/core'
|
|
|
16
17
|
let cssInjected = false
|
|
17
18
|
let shikiInstance = null
|
|
18
19
|
let shikiLoadPromise = null
|
|
20
|
+
let siteThemeLoaded = false
|
|
21
|
+
|
|
22
|
+
// What a site gets when it declares no `code:` block of its own, and the base
|
|
23
|
+
// every declaration layers over.
|
|
24
|
+
const DEFAULT_THEME = 'github-dark'
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
|
-
*
|
|
27
|
+
* Where each documented `theme.code` key lands in TextMate scope terms.
|
|
28
|
+
*
|
|
29
|
+
* Shiki colours by scope, so a declaration only reaches the output by becoming
|
|
30
|
+
* a scope rule. This used to map the same keys onto `--shiki-*` CSS variables,
|
|
31
|
+
* which could never take effect: Shiki writes its theme as an inline style on
|
|
32
|
+
* the <pre> and on every token span, and an inline style outranks any
|
|
33
|
+
* stylesheet. The `code:` block was documented, parsed, and inert.
|
|
34
|
+
*
|
|
35
|
+
* `lineNumber` and `selection` are deliberately absent. They are editor chrome,
|
|
36
|
+
* not token scopes, and Shiki's HTML has neither.
|
|
37
|
+
*/
|
|
38
|
+
const SCOPE_MAP = {
|
|
39
|
+
comment: ['comment', 'punctuation.definition.comment'],
|
|
40
|
+
string: ['string', 'string.quoted', 'constant.other.symbol'],
|
|
41
|
+
keyword: ['keyword', 'storage', 'storage.type', 'keyword.control'],
|
|
42
|
+
operator: ['keyword.operator'],
|
|
43
|
+
function: ['entity.name.function', 'support.function', 'meta.function-call'],
|
|
44
|
+
variable: ['variable', 'variable.other.readwrite'],
|
|
45
|
+
number: ['constant.numeric'],
|
|
46
|
+
constant: ['constant.language', 'constant.character', 'support.constant'],
|
|
47
|
+
type: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class'],
|
|
48
|
+
property: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'],
|
|
49
|
+
tag: ['entity.name.tag'],
|
|
50
|
+
attribute: ['entity.other.attribute-name'],
|
|
51
|
+
punctuation: ['punctuation'],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SITE_CODE_THEME = 'uniweb-site-code'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a Shiki theme from a site's `theme.yml` `code:` block, layered over a
|
|
58
|
+
* base theme.
|
|
59
|
+
*
|
|
60
|
+
* Layering rather than replacing is the point. Most sites declare `background:`
|
|
61
|
+
* alone — "the same highlighting, on my surface" — and building a theme from
|
|
62
|
+
* only the declared keys would strip every syntax colour to answer that. So the
|
|
63
|
+
* base supplies everything, and each declared key overrides its own scopes.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} base - A resolved Shiki theme to layer over
|
|
66
|
+
* @param {Object} code - The site's `theme.code` declaration
|
|
67
|
+
* @returns {Object} A Shiki theme registration
|
|
22
68
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
export function buildCodeTheme(base, code) {
|
|
70
|
+
const theme = {
|
|
71
|
+
...base,
|
|
72
|
+
name: SITE_CODE_THEME,
|
|
73
|
+
colors: { ...base?.colors },
|
|
74
|
+
// Declared scopes go last so they win over the base's own rules.
|
|
75
|
+
settings: [...(base?.settings ?? [])],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (code?.background) {
|
|
79
|
+
theme.bg = code.background
|
|
80
|
+
theme.colors['editor.background'] = code.background
|
|
81
|
+
}
|
|
82
|
+
if (code?.foreground) {
|
|
83
|
+
theme.fg = code.foreground
|
|
84
|
+
theme.colors['editor.foreground'] = code.foreground
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const [key, scope] of Object.entries(SCOPE_MAP)) {
|
|
88
|
+
if (code?.[key]) theme.settings.push({ scope, settings: { foreground: code[key] } })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return theme
|
|
41
92
|
}
|
|
42
93
|
|
|
43
94
|
/**
|
|
44
|
-
*
|
|
95
|
+
* Layout for the highlighted block. Colour is not set here — Shiki writes the
|
|
96
|
+
* theme's own inline, and the theme is now the site's (see buildCodeTheme).
|
|
97
|
+
* What is left is spacing and the code face, so a listing is legible before a
|
|
98
|
+
* foundation styles it, without kit deciding what colour anything is.
|
|
45
99
|
*/
|
|
46
|
-
function
|
|
100
|
+
function injectCodeLayoutCSS() {
|
|
47
101
|
if (cssInjected || typeof document === 'undefined') return
|
|
48
102
|
|
|
49
|
-
const styleId = 'uniweb-code-
|
|
103
|
+
const styleId = 'uniweb-code-layout'
|
|
50
104
|
|
|
51
105
|
// Check if already injected (e.g., by another component instance)
|
|
52
106
|
if (document.getElementById(styleId)) {
|
|
@@ -54,38 +108,15 @@ function injectCodeThemeCSS(codeTheme) {
|
|
|
54
108
|
return
|
|
55
109
|
}
|
|
56
110
|
|
|
57
|
-
// Build CSS variables
|
|
58
|
-
const cssVars = []
|
|
59
|
-
for (const [key, value] of Object.entries(codeTheme || {})) {
|
|
60
|
-
const varName = CSS_VAR_MAP[key]
|
|
61
|
-
if (varName && value) {
|
|
62
|
-
cssVars.push(`${varName}: ${value};`)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Create and inject style element
|
|
67
111
|
const style = document.createElement('style')
|
|
68
112
|
style.id = styleId
|
|
69
113
|
style.textContent = `
|
|
70
|
-
:root {
|
|
71
|
-
${cssVars.join('\n ')}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/* Code block base styles */
|
|
75
114
|
.shiki {
|
|
76
|
-
background-color: var(--shiki-background, #1e1e2e);
|
|
77
|
-
color: var(--shiki-foreground, #cdd6f4);
|
|
78
115
|
padding: 1rem;
|
|
79
116
|
border-radius: 0.5rem;
|
|
80
117
|
overflow-x: auto;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
|
-
/* Ensure proper token colors */
|
|
84
|
-
.shiki span {
|
|
85
|
-
color: var(--shiki-token-foreground, inherit);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/* Code element inside shiki */
|
|
89
120
|
.shiki code {
|
|
90
121
|
display: block;
|
|
91
122
|
font-family: var(--font-code, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace);
|
|
@@ -138,13 +169,33 @@ async function loadShiki() {
|
|
|
138
169
|
return shikiLoadPromise
|
|
139
170
|
}
|
|
140
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Register the site's `code:` declaration as a theme, once, and answer with the
|
|
174
|
+
* theme name to highlight against. Sites that declare nothing get the default.
|
|
175
|
+
*/
|
|
176
|
+
async function resolveThemeName(highlighter, codeTheme) {
|
|
177
|
+
if (!codeTheme || Object.keys(codeTheme).length === 0) return DEFAULT_THEME
|
|
178
|
+
if (siteThemeLoaded) return SITE_CODE_THEME
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await highlighter.loadTheme(buildCodeTheme(highlighter.getTheme(DEFAULT_THEME), codeTheme))
|
|
182
|
+
siteThemeLoaded = true
|
|
183
|
+
return SITE_CODE_THEME
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('[Code] Could not apply theme.code, using the default:', error)
|
|
186
|
+
return DEFAULT_THEME
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
141
190
|
/**
|
|
142
191
|
* Highlight code using Shiki
|
|
143
192
|
*/
|
|
144
|
-
async function highlightCode(code, language, highlighter) {
|
|
193
|
+
async function highlightCode(code, language, highlighter, codeTheme) {
|
|
145
194
|
if (!highlighter) return null
|
|
146
195
|
|
|
147
196
|
try {
|
|
197
|
+
const theme = await resolveThemeName(highlighter, codeTheme)
|
|
198
|
+
|
|
148
199
|
// Load language if not already loaded
|
|
149
200
|
const loadedLangs = highlighter.getLoadedLanguages()
|
|
150
201
|
const lang = language?.toLowerCase() || 'plaintext'
|
|
@@ -154,16 +205,13 @@ async function highlightCode(code, language, highlighter) {
|
|
|
154
205
|
await highlighter.loadLanguage(lang)
|
|
155
206
|
} catch {
|
|
156
207
|
// Language not available, fall back to plaintext
|
|
157
|
-
return highlighter.codeToHtml(code, {
|
|
158
|
-
lang: 'plaintext',
|
|
159
|
-
theme: 'github-dark',
|
|
160
|
-
})
|
|
208
|
+
return highlighter.codeToHtml(code, { lang: 'plaintext', theme })
|
|
161
209
|
}
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
return highlighter.codeToHtml(code, {
|
|
165
213
|
lang: lang === 'plaintext' ? 'text' : lang,
|
|
166
|
-
theme
|
|
214
|
+
theme,
|
|
167
215
|
})
|
|
168
216
|
} catch (error) {
|
|
169
217
|
console.warn('[Code] Highlighting failed:', error)
|
|
@@ -207,12 +255,11 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
207
255
|
return aliases[l] || l
|
|
208
256
|
}, [language])
|
|
209
257
|
|
|
210
|
-
// Inject CSS on first render (if in browser)
|
|
258
|
+
// Inject layout CSS on first render (if in browser). Unlike the colours, this
|
|
259
|
+
// is wanted whether or not the site declared a `code:` block.
|
|
211
260
|
useEffect(() => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
}, [codeTheme])
|
|
261
|
+
injectCodeLayoutCSS()
|
|
262
|
+
}, [])
|
|
216
263
|
|
|
217
264
|
// Load Shiki and highlight code
|
|
218
265
|
useEffect(() => {
|
|
@@ -223,7 +270,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
223
270
|
if (cancelled) return
|
|
224
271
|
|
|
225
272
|
if (highlighter && content) {
|
|
226
|
-
const html = await highlightCode(content, lang, highlighter)
|
|
273
|
+
const html = await highlightCode(content, lang, highlighter, codeTheme)
|
|
227
274
|
if (!cancelled) {
|
|
228
275
|
setHighlightedHtml(html)
|
|
229
276
|
}
|
|
@@ -235,7 +282,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
235
282
|
return () => {
|
|
236
283
|
cancelled = true
|
|
237
284
|
}
|
|
238
|
-
}, [content, lang])
|
|
285
|
+
}, [content, lang, codeTheme])
|
|
239
286
|
|
|
240
287
|
// Render highlighted code or fallback
|
|
241
288
|
if (highlightedHtml) {
|
|
@@ -248,18 +295,19 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
248
295
|
)
|
|
249
296
|
}
|
|
250
297
|
|
|
251
|
-
// Fallback: plain code block (shown before Shiki loads or if it fails)
|
|
252
|
-
// No loading indicator
|
|
253
|
-
//
|
|
298
|
+
// Fallback: plain code block (shown before Shiki loads or if it fails).
|
|
299
|
+
// No loading indicator — the code is already readable, and the syntax colours
|
|
300
|
+
// arrive when Shiki does. Semantic tokens, so the wait looks like the site
|
|
301
|
+
// rather than like a fixed grey no theme asked for.
|
|
254
302
|
return (
|
|
255
303
|
<pre
|
|
256
304
|
className={cn(
|
|
257
|
-
'overflow-x-auto rounded-lg bg-
|
|
305
|
+
'overflow-x-auto rounded-lg bg-muted p-4 text-sm text-body',
|
|
258
306
|
className
|
|
259
307
|
)}
|
|
260
308
|
{...props}
|
|
261
309
|
>
|
|
262
|
-
<code className={`language-${lang}
|
|
310
|
+
<code className={`language-${lang}`}>
|
|
263
311
|
{content}
|
|
264
312
|
</code>
|
|
265
313
|
</pre>
|
package/src/styled/index.js
CHANGED
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
* For unstyled primitives, use the main '@uniweb/kit' export.
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
10
|
-
* import {
|
|
10
|
+
* import { Section, Prose, Media } from '@uniweb/kit (styled)'
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
// ============================================================================
|
|
14
14
|
// Layout
|
|
15
15
|
// ============================================================================
|
|
16
16
|
|
|
17
|
-
export { SidebarLayout } from './SidebarLayout/index.js'
|
|
18
17
|
|
|
19
18
|
// ============================================================================
|
|
20
19
|
// Content Rendering
|
package/src/utils/index.js
CHANGED
|
@@ -248,3 +248,37 @@ export function detectMediaType(url) {
|
|
|
248
248
|
// ─────────────────────────────────────────────────────────────────
|
|
249
249
|
|
|
250
250
|
export { submitForm, derivePreviewFromFormData } from './submitForm.js'
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The text of a ProseMirror node, flattened.
|
|
254
|
+
*
|
|
255
|
+
* @param {Object|string} node - A ProseMirror node
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
export function nodeText(node) {
|
|
259
|
+
if (!node) return ''
|
|
260
|
+
if (typeof node === 'string') return node
|
|
261
|
+
if (node.text) return node.text
|
|
262
|
+
if (node.content) return node.content.map(nodeText).join('')
|
|
263
|
+
return ''
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The anchor id for a heading.
|
|
268
|
+
*
|
|
269
|
+
* One generator, used by every renderer that stamps a heading and by anything
|
|
270
|
+
* that links to one. That agreement is the whole point: a table of contents
|
|
271
|
+
* scrolls to an id some other module produced, so a second implementation is a
|
|
272
|
+
* drift waiting to happen — and there were two, differing in whether they
|
|
273
|
+
* stripped markup first, which decided whether a heading containing a link got
|
|
274
|
+
* a usable anchor.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} text - Heading text, plain or containing markup
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function headingId(text) {
|
|
280
|
+
return stripTags(String(text ?? ''))
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
283
|
+
.replace(/^-|-$/g, '')
|
|
284
|
+
}
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import React, { useEffect } from 'react'
|
|
2
|
-
import { cn } from '../../utils/index.js'
|
|
3
|
-
import { useMobileMenu } from '../../hooks/useMobileMenu.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* SidebarLayout Component
|
|
7
|
-
*
|
|
8
|
-
* A flexible layout with optional left and/or right sidebars.
|
|
9
|
-
* On desktop, sidebars appear inline at their configured breakpoints.
|
|
10
|
-
* On mobile, the left panel is accessible via a slide-out drawer with FAB toggle.
|
|
11
|
-
* The right panel is hidden on mobile (common pattern: nav essential, TOC optional).
|
|
12
|
-
*
|
|
13
|
-
* The layout is "sandwiched" - header and footer span the full width,
|
|
14
|
-
* with the sidebars and content area between them.
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* // In foundation's src/foundation.js
|
|
18
|
-
* import { SidebarLayout } from '@uniweb/kit'
|
|
19
|
-
*
|
|
20
|
-
* export default {
|
|
21
|
-
* Layout: SidebarLayout,
|
|
22
|
-
* }
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* // With custom configuration
|
|
26
|
-
* import { SidebarLayout } from '@uniweb/kit'
|
|
27
|
-
*
|
|
28
|
-
* function CustomLayout(props) {
|
|
29
|
-
* return (
|
|
30
|
-
* <SidebarLayout
|
|
31
|
-
* {...props}
|
|
32
|
-
* leftBreakpoint="lg"
|
|
33
|
-
* rightBreakpoint="xl"
|
|
34
|
-
* leftWidth="w-72"
|
|
35
|
-
* />
|
|
36
|
-
* )
|
|
37
|
-
* }
|
|
38
|
-
*
|
|
39
|
-
* export default { Layout: CustomLayout }
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Hamburger menu icon
|
|
44
|
-
*/
|
|
45
|
-
function MenuIcon({ className }) {
|
|
46
|
-
return (
|
|
47
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
48
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
49
|
-
</svg>
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Close (X) icon
|
|
55
|
-
*/
|
|
56
|
-
function CloseIcon({ className }) {
|
|
57
|
-
return (
|
|
58
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
59
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
60
|
-
</svg>
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Mobile drawer component (always slides from left)
|
|
66
|
-
*/
|
|
67
|
-
function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
|
|
68
|
-
// Prevent body scroll when drawer is open
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (isOpen) {
|
|
71
|
-
document.body.style.overflow = 'hidden'
|
|
72
|
-
return () => {
|
|
73
|
-
document.body.style.overflow = ''
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}, [isOpen])
|
|
77
|
-
|
|
78
|
-
// Position below header if sticky, otherwise from top
|
|
79
|
-
const topOffset = stickyHeader ? 'top-16' : 'top-0'
|
|
80
|
-
const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
|
|
81
|
-
|
|
82
|
-
return (
|
|
83
|
-
<>
|
|
84
|
-
{/* Backdrop */}
|
|
85
|
-
<div
|
|
86
|
-
className={cn(
|
|
87
|
-
'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
|
|
88
|
-
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
89
|
-
)}
|
|
90
|
-
onClick={onClose}
|
|
91
|
-
aria-hidden="true"
|
|
92
|
-
/>
|
|
93
|
-
|
|
94
|
-
{/* Drawer (always from left) */}
|
|
95
|
-
<div
|
|
96
|
-
className={cn(
|
|
97
|
-
'fixed left-0 bg-white z-50 shadow-xl',
|
|
98
|
-
topOffset,
|
|
99
|
-
height,
|
|
100
|
-
width,
|
|
101
|
-
'transform transition-transform duration-300 ease-in-out',
|
|
102
|
-
isOpen ? 'translate-x-0' : '-translate-x-full'
|
|
103
|
-
)}
|
|
104
|
-
role="dialog"
|
|
105
|
-
aria-modal="true"
|
|
106
|
-
aria-label="Sidebar navigation"
|
|
107
|
-
>
|
|
108
|
-
{/* Close button */}
|
|
109
|
-
<button
|
|
110
|
-
onClick={onClose}
|
|
111
|
-
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
|
112
|
-
aria-label="Close sidebar"
|
|
113
|
-
>
|
|
114
|
-
<CloseIcon className="w-5 h-5 text-gray-500" />
|
|
115
|
-
</button>
|
|
116
|
-
|
|
117
|
-
{/* Drawer content */}
|
|
118
|
-
<div className="h-full overflow-y-auto overscroll-contain">
|
|
119
|
-
{children}
|
|
120
|
-
</div>
|
|
121
|
-
</div>
|
|
122
|
-
</>
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Floating action button for mobile menu (always bottom-left)
|
|
128
|
-
*/
|
|
129
|
-
function FloatingMenuButton({ onClick }) {
|
|
130
|
-
return (
|
|
131
|
-
<button
|
|
132
|
-
onClick={onClick}
|
|
133
|
-
className={cn(
|
|
134
|
-
'fixed bottom-4 left-4 z-30',
|
|
135
|
-
'p-3 bg-primary text-white rounded-full shadow-lg',
|
|
136
|
-
'hover:bg-primary/90 active:scale-95 transition-all',
|
|
137
|
-
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
|
|
138
|
-
)}
|
|
139
|
-
aria-label="Open navigation menu"
|
|
140
|
-
>
|
|
141
|
-
<MenuIcon className="w-6 h-6" />
|
|
142
|
-
</button>
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Get responsive classes for showing/hiding at breakpoint
|
|
148
|
-
*/
|
|
149
|
-
function getBreakpointClasses(breakpoint) {
|
|
150
|
-
const showClass = {
|
|
151
|
-
sm: 'sm:block',
|
|
152
|
-
md: 'md:block',
|
|
153
|
-
lg: 'lg:block',
|
|
154
|
-
xl: 'xl:block',
|
|
155
|
-
}[breakpoint] || 'md:block'
|
|
156
|
-
|
|
157
|
-
const hideClass = {
|
|
158
|
-
sm: 'sm:hidden',
|
|
159
|
-
md: 'md:hidden',
|
|
160
|
-
lg: 'lg:hidden',
|
|
161
|
-
xl: 'xl:hidden',
|
|
162
|
-
}[breakpoint] || 'md:hidden'
|
|
163
|
-
|
|
164
|
-
return { showClass, hideClass }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* SidebarLayout main component
|
|
169
|
-
*
|
|
170
|
-
* @param {Object} props
|
|
171
|
-
* @param {React.ReactNode} props.header - Header content (from layout/header.md)
|
|
172
|
-
* @param {React.ReactNode} props.body - Main body content (page sections)
|
|
173
|
-
* @param {React.ReactNode} props.footer - Footer content (from layout/footer.md)
|
|
174
|
-
* @param {React.ReactNode} props.left - Left panel content (from layout/left.md)
|
|
175
|
-
* @param {React.ReactNode} props.right - Right panel content (from layout/right.md)
|
|
176
|
-
* @param {React.ReactNode} props.leftPanel - Alias for left (backwards compatibility)
|
|
177
|
-
* @param {React.ReactNode} props.rightPanel - Alias for right (backwards compatibility)
|
|
178
|
-
* @param {string} [props.leftWidth='w-64'] - Tailwind width class for left sidebar
|
|
179
|
-
* @param {string} [props.rightWidth='w-64'] - Tailwind width class for right sidebar
|
|
180
|
-
* @param {string} [props.drawerWidth='w-72'] - Tailwind width class for mobile drawer
|
|
181
|
-
* @param {string} [props.leftBreakpoint='md'] - Breakpoint for showing left sidebar inline
|
|
182
|
-
* @param {string} [props.rightBreakpoint='xl'] - Breakpoint for showing right sidebar inline
|
|
183
|
-
* @param {boolean} [props.stickyHeader=true] - Whether header sticks to top
|
|
184
|
-
* @param {boolean} [props.stickySidebar=true] - Whether sidebars stick below header
|
|
185
|
-
* @param {string} [props.maxWidth='max-w-7xl'] - Max width of content area
|
|
186
|
-
* @param {string} [props.contentPadding='px-4 py-8 sm:px-6 lg:px-8'] - Padding for main content
|
|
187
|
-
* @param {string} [props.className] - Additional classes for the root element
|
|
188
|
-
*/
|
|
189
|
-
export function SidebarLayout({
|
|
190
|
-
// Pre-rendered layout areas from runtime
|
|
191
|
-
header,
|
|
192
|
-
body,
|
|
193
|
-
footer,
|
|
194
|
-
left,
|
|
195
|
-
right,
|
|
196
|
-
leftPanel,
|
|
197
|
-
rightPanel,
|
|
198
|
-
// Configuration
|
|
199
|
-
leftWidth = 'w-64',
|
|
200
|
-
rightWidth = 'w-64',
|
|
201
|
-
drawerWidth = 'w-72',
|
|
202
|
-
leftBreakpoint = 'md',
|
|
203
|
-
rightBreakpoint = 'xl',
|
|
204
|
-
stickyHeader = true,
|
|
205
|
-
stickySidebar = true,
|
|
206
|
-
maxWidth = 'max-w-7xl',
|
|
207
|
-
contentPadding = 'px-4 py-8 sm:px-6 lg:px-8',
|
|
208
|
-
className,
|
|
209
|
-
}) {
|
|
210
|
-
const { isOpen, open, close } = useMobileMenu()
|
|
211
|
-
|
|
212
|
-
// Resolve panel content (support both naming conventions)
|
|
213
|
-
const leftContent = left || leftPanel
|
|
214
|
-
const rightContent = right || rightPanel
|
|
215
|
-
|
|
216
|
-
// Get breakpoint classes for each panel
|
|
217
|
-
const leftClasses = getBreakpointClasses(leftBreakpoint)
|
|
218
|
-
const rightClasses = getBreakpointClasses(rightBreakpoint)
|
|
219
|
-
|
|
220
|
-
// Sticky positioning
|
|
221
|
-
const headerClasses = stickyHeader
|
|
222
|
-
? 'sticky top-0 z-30'
|
|
223
|
-
: ''
|
|
224
|
-
|
|
225
|
-
const sidebarClasses = stickySidebar && stickyHeader
|
|
226
|
-
? 'sticky top-16 h-[calc(100vh-4rem)]'
|
|
227
|
-
: stickySidebar
|
|
228
|
-
? 'sticky top-0 h-screen'
|
|
229
|
-
: ''
|
|
230
|
-
|
|
231
|
-
return (
|
|
232
|
-
<div className={cn('min-h-screen flex flex-col bg-white', className)}>
|
|
233
|
-
{/* Header */}
|
|
234
|
-
{header && (
|
|
235
|
-
<header className={cn(
|
|
236
|
-
'w-full border-b border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80',
|
|
237
|
-
headerClasses
|
|
238
|
-
)}>
|
|
239
|
-
{header}
|
|
240
|
-
</header>
|
|
241
|
-
)}
|
|
242
|
-
|
|
243
|
-
{/* Mobile Drawer (left panel only) */}
|
|
244
|
-
{leftContent && (
|
|
245
|
-
<div className={leftClasses.hideClass}>
|
|
246
|
-
<MobileDrawer
|
|
247
|
-
isOpen={isOpen}
|
|
248
|
-
onClose={close}
|
|
249
|
-
width={drawerWidth}
|
|
250
|
-
stickyHeader={stickyHeader}
|
|
251
|
-
>
|
|
252
|
-
{leftContent}
|
|
253
|
-
</MobileDrawer>
|
|
254
|
-
</div>
|
|
255
|
-
)}
|
|
256
|
-
|
|
257
|
-
{/* Main Content Area */}
|
|
258
|
-
<div className={cn('flex-1 w-full mx-auto', maxWidth)}>
|
|
259
|
-
<div className="flex">
|
|
260
|
-
{/* Left Sidebar (desktop) */}
|
|
261
|
-
{leftContent && (
|
|
262
|
-
<aside className={cn(
|
|
263
|
-
'hidden flex-shrink-0 overflow-y-auto border-r border-gray-200',
|
|
264
|
-
leftClasses.showClass,
|
|
265
|
-
leftWidth,
|
|
266
|
-
sidebarClasses
|
|
267
|
-
)}>
|
|
268
|
-
{leftContent}
|
|
269
|
-
</aside>
|
|
270
|
-
)}
|
|
271
|
-
|
|
272
|
-
{/* Main Content */}
|
|
273
|
-
<main className="flex-1 min-w-0">
|
|
274
|
-
<div className={contentPadding}>
|
|
275
|
-
{body}
|
|
276
|
-
</div>
|
|
277
|
-
</main>
|
|
278
|
-
|
|
279
|
-
{/* Right Sidebar (desktop only, hidden on mobile) */}
|
|
280
|
-
{rightContent && (
|
|
281
|
-
<aside className={cn(
|
|
282
|
-
'hidden flex-shrink-0 overflow-y-auto border-l border-gray-200',
|
|
283
|
-
rightClasses.showClass,
|
|
284
|
-
rightWidth,
|
|
285
|
-
sidebarClasses
|
|
286
|
-
)}>
|
|
287
|
-
{rightContent}
|
|
288
|
-
</aside>
|
|
289
|
-
)}
|
|
290
|
-
</div>
|
|
291
|
-
</div>
|
|
292
|
-
|
|
293
|
-
{/* Footer */}
|
|
294
|
-
{footer && (
|
|
295
|
-
<footer className="w-full border-t border-gray-200">
|
|
296
|
-
{footer}
|
|
297
|
-
</footer>
|
|
298
|
-
)}
|
|
299
|
-
|
|
300
|
-
{/* Mobile FAB (only if left panel exists) */}
|
|
301
|
-
{leftContent && (
|
|
302
|
-
<div className={leftClasses.hideClass}>
|
|
303
|
-
<FloatingMenuButton onClick={open} />
|
|
304
|
-
</div>
|
|
305
|
-
)}
|
|
306
|
-
</div>
|
|
307
|
-
)
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export default SidebarLayout
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { SidebarLayout, default } from './SidebarLayout.jsx'
|