@janga/norna 0.7.24 → 0.7.25
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/README.md +2 -2
- package/astro.config.mjs +2 -0
- package/package.json +14 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +56 -21
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +241 -241
- package/scripts/check-config.mjs +5 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/init-site.mjs +2 -0
- package/scripts/lib/code-fence-metadata.mjs +11 -6
- package/scripts/lib/edit-source-link.mjs +53 -0
- package/scripts/lib/navigation-model.mjs +1 -1
- package/scripts/lib/norna-markdown-blocks.mjs +91 -0
- package/scripts/lib/project-config.mjs +42 -12
- package/scripts/lib/schema-definitions.mjs +10 -2
- package/scripts/lib/schema-editor-metadata.mjs +20 -3
- package/scripts/lib/schema-value-definitions.mjs +3 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +2 -1
- package/src/components/ImageCarousel.astro +4 -1
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +73 -49
- package/src/components/SitePage.astro +38 -23
- package/src/components/SiteTreeNavigation.astro +3 -0
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +78 -22
- package/src/layouts/BaseLayout.astro +31 -5
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +4 -27
- package/src/lib/sitePages.ts +6 -3
- package/src/styles/content.css +298 -23
- package/src/styles/media.css +187 -3
- package/src/styles/navigation.css +168 -3
- package/src/styles/page-layout.css +48 -16
- package/src/styles/responsive.css +67 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { Search } from '@lucide/astro';
|
|
2
|
+
import { Menu, Search, X } from '@lucide/astro';
|
|
3
3
|
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
4
|
import { withBasePath } from '../lib/basePath';
|
|
5
5
|
import type { SectionNavigation } from '../lib/sectionContent';
|
|
@@ -177,56 +177,80 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
|
|
|
177
177
|
<DisplaySettings controls={readerPreferences.controls} defaults={readerPreferences.defaults} />
|
|
178
178
|
)}
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
180
|
+
{showMobileNavigation && (
|
|
181
|
+
<details class="mobile-nav-menu" data-compact-navigation>
|
|
182
|
+
<summary>
|
|
183
|
+
<span>{projectConfig.locale.labels.navigationMenu}</span>
|
|
184
|
+
<Menu class="mobile-nav-trigger-icon mobile-nav-trigger-open" aria-hidden="true" />
|
|
185
|
+
<X class="mobile-nav-trigger-icon mobile-nav-trigger-close" aria-hidden="true" />
|
|
186
|
+
</summary>
|
|
187
|
+
<div
|
|
188
|
+
class="mobile-nav-panel"
|
|
189
|
+
data-compact-navigation-panel
|
|
190
|
+
role="dialog"
|
|
191
|
+
aria-modal="true"
|
|
192
|
+
aria-label={projectConfig.locale.labels.navigationMenu}
|
|
193
|
+
>
|
|
194
|
+
<div class="mobile-nav-panel-actions">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
class="mobile-nav-close"
|
|
198
|
+
data-compact-navigation-close
|
|
199
|
+
aria-label={projectConfig.locale.labels.closeNavigation}
|
|
200
|
+
>
|
|
201
|
+
<X aria-hidden="true" />
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div
|
|
205
|
+
class="mobile-site-nav"
|
|
206
|
+
data-navigation-root={pageHref(homePage)}
|
|
207
|
+
data-navigation-state-root={pageHref(homePage)}
|
|
208
|
+
>
|
|
209
|
+
{showSiteNavigation && (
|
|
210
|
+
<nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
|
|
211
|
+
<h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
|
|
212
|
+
<NavigationTreeControls
|
|
213
|
+
idPrefix="mobile-tree-navigation"
|
|
214
|
+
nodes={siteNavigationTree}
|
|
215
|
+
/>
|
|
216
|
+
<NavigationPageTree
|
|
217
|
+
nodes={siteNavigationTree}
|
|
218
|
+
currentPage={currentPage}
|
|
219
|
+
variant="mobile"
|
|
220
|
+
integratePageSections={navigationMode === 'tree'}
|
|
221
|
+
/>
|
|
222
|
+
</nav>
|
|
223
|
+
)}
|
|
201
224
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
{navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && currentPage && (
|
|
226
|
+
<nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.pageNavigation}>
|
|
227
|
+
{!showSinglePageTopLink && (
|
|
228
|
+
<h2 tabindex="-1">{projectConfig.locale.labels.pageNavigation}</h2>
|
|
229
|
+
)}
|
|
230
|
+
<ul class="mobile-nav-sections">
|
|
231
|
+
{showSinglePageTopLink && (
|
|
232
|
+
<li class="mobile-nav-page-top-item">
|
|
233
|
+
<a
|
|
234
|
+
class="mobile-nav-destination mobile-nav-page-top"
|
|
235
|
+
data-page-top
|
|
236
|
+
href={pageHref(currentPage)}
|
|
237
|
+
>
|
|
238
|
+
{currentPage.title}
|
|
239
|
+
</a>
|
|
240
|
+
</li>
|
|
241
|
+
)}
|
|
242
|
+
{currentPageNavigation.sections.map((section) => (
|
|
243
|
+
<li>
|
|
244
|
+
<a class="mobile-nav-destination" href={`#${section.id}`}>{section.title}</a>
|
|
245
|
+
</li>
|
|
246
|
+
))}
|
|
247
|
+
</ul>
|
|
248
|
+
</nav>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</details>
|
|
226
253
|
)}
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
</details>}
|
|
230
254
|
</div>
|
|
231
255
|
</div>
|
|
232
256
|
)}
|
|
@@ -3,6 +3,7 @@ import { getCollection, type CollectionEntry } from 'astro:content';
|
|
|
3
3
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
4
4
|
import CodeBlockCopyScript from './CodeBlockCopyScript.astro';
|
|
5
5
|
import EditSourceLink from './EditSourceLink.astro';
|
|
6
|
+
import ImageStackEnhancement from './ImageStackEnhancement.astro';
|
|
6
7
|
import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
|
|
7
8
|
import SiteNavigation from './SiteNavigation.astro';
|
|
8
9
|
import SiteBanners from './SiteBanners.astro';
|
|
@@ -10,6 +11,7 @@ import PageContentsNavigation from './PageContentsNavigation.astro';
|
|
|
10
11
|
import PageSequenceNavigation from './PageSequenceNavigation.astro';
|
|
11
12
|
import SiteSection from './SiteSection.astro';
|
|
12
13
|
import SiteTreeNavigation from './SiteTreeNavigation.astro';
|
|
14
|
+
import TableOverflowScript from './TableOverflowScript.astro';
|
|
13
15
|
import TreeNavigationScript from './TreeNavigationScript.astro';
|
|
14
16
|
import { getSectionsContent, type ResolvedSection } from '../lib/sectionContent';
|
|
15
17
|
import { getPageTheme } from '../lib/pageThemes';
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
resolvePageContentsPlacement,
|
|
28
30
|
} from '../../scripts/lib/navigation-model.mjs';
|
|
29
31
|
import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
|
|
30
|
-
import {
|
|
32
|
+
import { resolveEditSourceTarget } from '../../scripts/lib/edit-source-link.mjs';
|
|
31
33
|
import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
|
|
32
34
|
import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
|
|
33
35
|
|
|
@@ -96,6 +98,12 @@ const resolvedSections = await getSectionsContent(siteHtml, currentPage, childPa
|
|
|
96
98
|
const hasCodeBlocks = resolvedSections.some((section) => section.contentBlocks.some((block) => (
|
|
97
99
|
block.type === 'html' && /<pre\b[^>]*>[\s\S]*?<code\b/i.test(block.html)
|
|
98
100
|
)));
|
|
101
|
+
const hasTables = resolvedSections.some((section) => section.contentBlocks.some((block) => (
|
|
102
|
+
block.type === 'html' && /\bdata-table-frame(?:="")?/i.test(block.html)
|
|
103
|
+
)));
|
|
104
|
+
const hasImageStack = resolvedSections.some((section) => section.contentBlocks.some((block) => (
|
|
105
|
+
block.type === 'image-stack'
|
|
106
|
+
)));
|
|
99
107
|
const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
|
|
100
108
|
block.type === 'image-stack'
|
|
101
109
|
|| block.type === 'image-carousel'
|
|
@@ -108,10 +116,13 @@ const renderedSections = resolvedSections.map((section, index) => ({
|
|
|
108
116
|
prioritizeFirstImage: index === firstImageSectionIndex,
|
|
109
117
|
}));
|
|
110
118
|
const hasPageSequence = Boolean(pageSequence.previous || pageSequence.next);
|
|
111
|
-
const
|
|
112
|
-
?
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
const editSourceTarget = projectConfig.editLink
|
|
120
|
+
? resolveEditSourceTarget({
|
|
121
|
+
...projectConfig.editLink,
|
|
122
|
+
development: import.meta.env.DEV,
|
|
123
|
+
hostname: Astro.url.hostname,
|
|
124
|
+
sourceLabel: currentPage.contentLabel,
|
|
125
|
+
sourcePath: currentPage.contentPath,
|
|
115
126
|
})
|
|
116
127
|
: null;
|
|
117
128
|
const navigationModel = resolveNavigationModel({
|
|
@@ -135,6 +146,7 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
|
|
|
135
146
|
const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
|
|
136
147
|
const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
|
|
137
148
|
const hasTreeNavigation = navigationModel.mode === 'tree';
|
|
149
|
+
const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
|
|
138
150
|
const currentPageNavigation = siteNavigation.find(({ node }) => (
|
|
139
151
|
node.kind === 'page' && node.pathname === currentPage.pathname
|
|
140
152
|
));
|
|
@@ -154,15 +166,19 @@ const pageContentsPlacement = resolvePageContentsPlacement({
|
|
|
154
166
|
headingCount: pageContentsHeadings.length,
|
|
155
167
|
});
|
|
156
168
|
const hasContentsNavigation = pageContentsPlacement.placement !== 'none';
|
|
157
|
-
const
|
|
169
|
+
const hasTrackedSectionNavigation = hasContentsNavigation
|
|
170
|
+
|| (hasTreeNavigation && currentPage.isHome && pageContentsHeadings.length >= 2);
|
|
171
|
+
const integratePageContentsInTree = hasTreeNavigation;
|
|
158
172
|
const showContentsRail = pageContentsPlacement.placement === 'contents-rail';
|
|
159
|
-
const hasNavigationRailFrame =
|
|
173
|
+
const hasNavigationRailFrame = !currentPage.isHome && (
|
|
174
|
+
navigationModel.requestedMode === 'tree'
|
|
160
175
|
|| (
|
|
161
176
|
navigationModel.requestedMode === 'automatic'
|
|
162
177
|
&& (navigationModel.hasNestedPages || navigationModel.hasCategories)
|
|
163
|
-
)
|
|
178
|
+
)
|
|
179
|
+
);
|
|
164
180
|
const showBreadcrumbs = !currentPage.isHome && currentPage.parentPagePath !== null;
|
|
165
|
-
const reserveBreadcrumbSpace =
|
|
181
|
+
const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
|
|
166
182
|
|
|
167
183
|
---
|
|
168
184
|
|
|
@@ -174,7 +190,7 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
174
190
|
frameColors={frameColors}
|
|
175
191
|
pathname={currentPage.pathname}
|
|
176
192
|
footer={sitewideData.footer}
|
|
177
|
-
contentsNavigation={
|
|
193
|
+
contentsNavigation={hasTrackedSectionNavigation}
|
|
178
194
|
searchable
|
|
179
195
|
>
|
|
180
196
|
<SiteNavigation
|
|
@@ -190,12 +206,12 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
190
206
|
{siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
|
|
191
207
|
|
|
192
208
|
<div class:list={['site-page-layout', {
|
|
193
|
-
'site-page-layout-tree':
|
|
209
|
+
'site-page-layout-tree': showTreeNavigation,
|
|
194
210
|
'site-page-layout-contents': showContentsRail,
|
|
195
211
|
}]}
|
|
196
212
|
data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
|
|
197
213
|
>
|
|
198
|
-
{
|
|
214
|
+
{showTreeNavigation && (
|
|
199
215
|
<SiteTreeNavigation
|
|
200
216
|
currentPage={currentPage}
|
|
201
217
|
navigationEntries={siteNavigation}
|
|
@@ -205,7 +221,7 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
205
221
|
<main
|
|
206
222
|
class:list={['site-content', {
|
|
207
223
|
'site-content-has-breadcrumbs': reserveBreadcrumbSpace,
|
|
208
|
-
'site-content-has-edit-source': Boolean(
|
|
224
|
+
'site-content-has-edit-source': Boolean(editSourceTarget),
|
|
209
225
|
'site-content-has-page-sequence': hasPageSequence,
|
|
210
226
|
}]}
|
|
211
227
|
id="main-content"
|
|
@@ -215,13 +231,6 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
215
231
|
{showBreadcrumbs
|
|
216
232
|
? <SiteBreadcrumbs currentPage={currentPage} nodes={siteNodes} />
|
|
217
233
|
: reserveBreadcrumbSpace && <div class="site-breadcrumbs" aria-hidden="true"></div>}
|
|
218
|
-
{showContentsRail && (
|
|
219
|
-
<PageContentsNavigation
|
|
220
|
-
headings={pageContentsHeadings}
|
|
221
|
-
pageTitle={currentPage.title}
|
|
222
|
-
variant="inline"
|
|
223
|
-
/>
|
|
224
|
-
)}
|
|
225
234
|
{renderedSections.map(({ section, sectionIndex, prioritizeFirstImage }) => (
|
|
226
235
|
<SiteSection
|
|
227
236
|
section={section}
|
|
@@ -231,8 +240,13 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
231
240
|
prioritizeFirstImage={prioritizeFirstImage}
|
|
232
241
|
/>
|
|
233
242
|
))}
|
|
234
|
-
{
|
|
235
|
-
<EditSourceLink
|
|
243
|
+
{editSourceTarget && (
|
|
244
|
+
<EditSourceLink
|
|
245
|
+
href={editSourceTarget.href}
|
|
246
|
+
label={editSourceTarget.kind === 'local'
|
|
247
|
+
? projectConfig.locale.labels.openInVsCode
|
|
248
|
+
: projectConfig.locale.labels.editSource}
|
|
249
|
+
/>
|
|
236
250
|
)}
|
|
237
251
|
<PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
|
|
238
252
|
</main>
|
|
@@ -240,10 +254,11 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
|
|
|
240
254
|
<PageContentsNavigation
|
|
241
255
|
headings={pageContentsHeadings}
|
|
242
256
|
pageTitle={currentPage.title}
|
|
243
|
-
variant="rail"
|
|
244
257
|
/>
|
|
245
258
|
)}
|
|
246
259
|
</div>
|
|
247
260
|
{hasTreeNavigation && <TreeNavigationScript />}
|
|
248
261
|
{hasCodeBlocks && <CodeBlockCopyScript />}
|
|
262
|
+
{hasTables && <TableOverflowScript />}
|
|
263
|
+
{hasImageStack && <ImageStackEnhancement />}
|
|
249
264
|
</BaseLayout>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
|
+
import { withBasePath } from '../lib/basePath';
|
|
3
4
|
import { getListedSiteNavigationTree, type SiteNavigationEntry } from '../lib/siteNavigation';
|
|
4
5
|
import type { SitePage } from '../lib/sitePages';
|
|
5
6
|
import NavigationPageTree from './NavigationPageTree.astro';
|
|
@@ -18,6 +19,7 @@ const activeRoot = roots.find((node) => (
|
|
|
18
19
|
|| currentPage.pageDirectory.startsWith(`${node.node.pageDirectory}/pages/`)
|
|
19
20
|
));
|
|
20
21
|
const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}/` : '/';
|
|
22
|
+
const navigationStateRoot = withBasePath(projectConfig.site.basePath, '/');
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
{activeRoot && (
|
|
@@ -25,6 +27,7 @@ const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}
|
|
|
25
27
|
id="tree-local-navigation"
|
|
26
28
|
class="tree-local-navigation"
|
|
27
29
|
data-navigation-root={navigationRoot}
|
|
30
|
+
data-navigation-state-root={navigationStateRoot}
|
|
28
31
|
>
|
|
29
32
|
<NavigationTreeControls
|
|
30
33
|
idPrefix="desktop-tree-navigation"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ChevronLeft, ChevronRight } from '@lucide/astro';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
|
|
5
|
+
const labels = projectConfig.locale.labels;
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<template data-table-navigation-template>
|
|
9
|
+
<div class="norna-table-navigation" data-table-navigation hidden>
|
|
10
|
+
<span class="visually-hidden" data-table-overflow-description>{labels.tableOverflowDescription}</span>
|
|
11
|
+
<div class="norna-table-navigation-buttons" role="group" aria-label={labels.tableColumns}>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
data-table-scroll-previous
|
|
15
|
+
aria-label={labels.tablePreviousColumns}
|
|
16
|
+
title={labels.tablePreviousColumns}
|
|
17
|
+
>
|
|
18
|
+
<ChevronLeft aria-hidden="true" size={18} stroke-width={1.8} />
|
|
19
|
+
</button>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
data-table-scroll-next
|
|
23
|
+
aria-label={labels.tableNextColumns}
|
|
24
|
+
title={labels.tableNextColumns}
|
|
25
|
+
>
|
|
26
|
+
<ChevronRight aria-hidden="true" size={18} stroke-width={1.8} />
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script>
|
|
33
|
+
type TableLayout = 'contained' | 'prose' | 'end' | 'canvas';
|
|
34
|
+
const layoutUpdaters = new Set<() => void>();
|
|
35
|
+
const navigationTemplate = document.querySelector<HTMLTemplateElement>('[data-table-navigation-template]');
|
|
36
|
+
const synchronizedAttributes = [
|
|
37
|
+
'data-appearance',
|
|
38
|
+
'data-focus-reading',
|
|
39
|
+
'data-reading-width',
|
|
40
|
+
'dir',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const initializeTableFrame = (frame: HTMLElement, frameIndex: number) => {
|
|
44
|
+
const scrollRegion = frame.querySelector<HTMLElement>('[data-table-scroll]');
|
|
45
|
+
const table = scrollRegion?.querySelector<HTMLTableElement>('table');
|
|
46
|
+
if (!scrollRegion || frame.dataset.tableReady === 'true') return;
|
|
47
|
+
const isTopLevel = frame.parentElement?.classList.contains('section-markdown') === true;
|
|
48
|
+
const tableHead = table?.tHead;
|
|
49
|
+
const headingCells = Array.from(tableHead?.querySelectorAll<HTMLTableCellElement>('th') ?? []);
|
|
50
|
+
let scheduledFrame: number | null = null;
|
|
51
|
+
let stickyHeading: HTMLElement | null = null;
|
|
52
|
+
let stickyHeadingTrack: HTMLElement | null = null;
|
|
53
|
+
let tableNavigation: HTMLElement | null = null;
|
|
54
|
+
let previousButton: HTMLButtonElement | null = null;
|
|
55
|
+
let nextButton: HTMLButtonElement | null = null;
|
|
56
|
+
let overflowDescriptionId: string | null = null;
|
|
57
|
+
|
|
58
|
+
const removeDuplicateIdentity = (element: HTMLElement) => {
|
|
59
|
+
element.removeAttribute('id');
|
|
60
|
+
element.querySelectorAll<HTMLElement>('[id]').forEach((descendant) => descendant.removeAttribute('id'));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const createStickyHeading = () => {
|
|
64
|
+
if (!isTopLevel || !tableHead || headingCells.length === 0) return;
|
|
65
|
+
|
|
66
|
+
stickyHeading = document.createElement('div');
|
|
67
|
+
stickyHeading.className = 'norna-table-sticky-heading';
|
|
68
|
+
stickyHeading.dataset.tableStickyHeading = '';
|
|
69
|
+
stickyHeading.setAttribute('aria-hidden', 'true');
|
|
70
|
+
stickyHeading.setAttribute('inert', '');
|
|
71
|
+
|
|
72
|
+
stickyHeadingTrack = document.createElement('div');
|
|
73
|
+
stickyHeadingTrack.className = 'norna-table-sticky-heading-track';
|
|
74
|
+
stickyHeading.append(stickyHeadingTrack);
|
|
75
|
+
frame.insertBefore(stickyHeading, scrollRegion);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const createTableNavigation = () => {
|
|
79
|
+
if (!navigationTemplate) return;
|
|
80
|
+
const fragment = navigationTemplate.content.cloneNode(true) as DocumentFragment;
|
|
81
|
+
tableNavigation = fragment.querySelector<HTMLElement>('[data-table-navigation]');
|
|
82
|
+
if (!tableNavigation) return;
|
|
83
|
+
|
|
84
|
+
const description = tableNavigation.querySelector<HTMLElement>('[data-table-overflow-description]');
|
|
85
|
+
previousButton = tableNavigation.querySelector<HTMLButtonElement>('[data-table-scroll-previous]');
|
|
86
|
+
nextButton = tableNavigation.querySelector<HTMLButtonElement>('[data-table-scroll-next]');
|
|
87
|
+
overflowDescriptionId = `norna-table-overflow-${frameIndex + 1}`;
|
|
88
|
+
if (description) description.id = overflowDescriptionId;
|
|
89
|
+
frame.insertBefore(fragment, scrollRegion);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const setOverflowDescription = (enabled: boolean) => {
|
|
93
|
+
if (!overflowDescriptionId) return;
|
|
94
|
+
const descriptionIds = new Set((scrollRegion.getAttribute('aria-describedby') ?? '')
|
|
95
|
+
.split(/\s+/u)
|
|
96
|
+
.filter(Boolean));
|
|
97
|
+
if (enabled) descriptionIds.add(overflowDescriptionId);
|
|
98
|
+
else descriptionIds.delete(overflowDescriptionId);
|
|
99
|
+
if (descriptionIds.size > 0) scrollRegion.setAttribute('aria-describedby', [...descriptionIds].join(' '));
|
|
100
|
+
else scrollRegion.removeAttribute('aria-describedby');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const scrollTable = (direction: -1 | 1) => {
|
|
104
|
+
const isRightToLeft = getComputedStyle(scrollRegion).direction === 'rtl';
|
|
105
|
+
const distance = Math.max(1, Math.round(scrollRegion.clientWidth * 0.8));
|
|
106
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
107
|
+
scrollRegion.scrollBy({
|
|
108
|
+
behavior: reducedMotion ? 'auto' : 'smooth',
|
|
109
|
+
left: direction * distance * (isRightToLeft ? -1 : 1),
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const synchronizeStickyHeadingPosition = () => {
|
|
114
|
+
if (!stickyHeading || !stickyHeadingTrack || !table) return;
|
|
115
|
+
const layerBounds = stickyHeading.getBoundingClientRect();
|
|
116
|
+
const tableBounds = table.getBoundingClientRect();
|
|
117
|
+
stickyHeadingTrack.style.transform = `translateX(${tableBounds.left - layerBounds.left}px)`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const synchronizeStickyHeadingGeometry = () => {
|
|
121
|
+
if (!stickyHeading || !stickyHeadingTrack || !table || !tableHead) return false;
|
|
122
|
+
const tableBounds = table.getBoundingClientRect();
|
|
123
|
+
const headingBounds = tableHead.getBoundingClientRect();
|
|
124
|
+
if (tableBounds.width <= 0 || headingBounds.height <= 0) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
frame.style.setProperty('--table-sticky-heading-height', `${headingBounds.height}px`);
|
|
129
|
+
stickyHeadingTrack.style.width = `${tableBounds.width}px`;
|
|
130
|
+
stickyHeadingTrack.style.height = `${headingBounds.height}px`;
|
|
131
|
+
stickyHeadingTrack.replaceChildren(...headingCells.map((headingCell) => {
|
|
132
|
+
const cellBounds = headingCell.getBoundingClientRect();
|
|
133
|
+
const computedStyle = getComputedStyle(headingCell);
|
|
134
|
+
const visualCell = document.createElement('div');
|
|
135
|
+
visualCell.className = 'norna-table-sticky-heading-cell';
|
|
136
|
+
visualCell.style.left = `${cellBounds.left - tableBounds.left}px`;
|
|
137
|
+
visualCell.style.top = `${cellBounds.top - headingBounds.top}px`;
|
|
138
|
+
visualCell.style.width = `${cellBounds.width}px`;
|
|
139
|
+
visualCell.style.height = `${cellBounds.height}px`;
|
|
140
|
+
visualCell.style.font = computedStyle.font;
|
|
141
|
+
visualCell.style.fontWeight = computedStyle.fontWeight;
|
|
142
|
+
visualCell.style.letterSpacing = computedStyle.letterSpacing;
|
|
143
|
+
visualCell.style.lineHeight = computedStyle.lineHeight;
|
|
144
|
+
visualCell.style.padding = computedStyle.padding;
|
|
145
|
+
visualCell.style.textAlign = computedStyle.textAlign;
|
|
146
|
+
visualCell.style.textTransform = computedStyle.textTransform;
|
|
147
|
+
visualCell.style.whiteSpace = computedStyle.whiteSpace;
|
|
148
|
+
visualCell.style.wordBreak = computedStyle.wordBreak;
|
|
149
|
+
visualCell.style.overflowWrap = computedStyle.overflowWrap;
|
|
150
|
+
visualCell.append(...Array.from(headingCell.childNodes, (node) => node.cloneNode(true)));
|
|
151
|
+
removeDuplicateIdentity(visualCell);
|
|
152
|
+
return visualCell;
|
|
153
|
+
}));
|
|
154
|
+
synchronizeStickyHeadingPosition();
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
createTableNavigation();
|
|
159
|
+
createStickyHeading();
|
|
160
|
+
previousButton?.addEventListener('click', () => scrollTable(-1));
|
|
161
|
+
nextButton?.addEventListener('click', () => scrollTable(1));
|
|
162
|
+
|
|
163
|
+
const updateOverflow = ({ refreshStickyHeading = false } = {}) => {
|
|
164
|
+
const maximumScroll = Math.max(0, scrollRegion.scrollWidth - scrollRegion.clientWidth);
|
|
165
|
+
const hasOverflow = maximumScroll > 1;
|
|
166
|
+
const scrollBounds = scrollRegion.getBoundingClientRect();
|
|
167
|
+
const tableBounds = table?.getBoundingClientRect();
|
|
168
|
+
const isRightToLeft = getComputedStyle(scrollRegion).direction === 'rtl';
|
|
169
|
+
const isAtStart = !hasOverflow || !tableBounds || (isRightToLeft
|
|
170
|
+
? tableBounds.right <= scrollBounds.right + 2
|
|
171
|
+
: tableBounds.left >= scrollBounds.left - 2);
|
|
172
|
+
const isAtEnd = !hasOverflow || !tableBounds || (isRightToLeft
|
|
173
|
+
? tableBounds.left >= scrollBounds.left - 2
|
|
174
|
+
: tableBounds.right <= scrollBounds.right + 2);
|
|
175
|
+
|
|
176
|
+
if (tableNavigation) {
|
|
177
|
+
tableNavigation.hidden = !hasOverflow;
|
|
178
|
+
frame.style.setProperty(
|
|
179
|
+
'--table-navigation-height',
|
|
180
|
+
hasOverflow ? `${tableNavigation.getBoundingClientRect().height}px` : '0px',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const stickyHeadingReady = hasOverflow && stickyHeading
|
|
184
|
+
? (refreshStickyHeading
|
|
185
|
+
? synchronizeStickyHeadingGeometry()
|
|
186
|
+
: (stickyHeadingTrack?.childElementCount ?? 0) > 0)
|
|
187
|
+
: false;
|
|
188
|
+
|
|
189
|
+
frame.dataset.tableOverflow = hasOverflow ? 'true' : 'false';
|
|
190
|
+
frame.dataset.tableAtStart = isAtStart ? 'true' : 'false';
|
|
191
|
+
frame.dataset.tableAtEnd = isAtEnd ? 'true' : 'false';
|
|
192
|
+
frame.dataset.tableStickyHeading = stickyHeadingReady ? 'true' : 'false';
|
|
193
|
+
if (previousButton) previousButton.disabled = isAtStart;
|
|
194
|
+
if (nextButton) nextButton.disabled = isAtEnd;
|
|
195
|
+
setOverflowDescription(hasOverflow);
|
|
196
|
+
if (hasOverflow) scrollRegion.setAttribute('tabindex', '0');
|
|
197
|
+
else scrollRegion.removeAttribute('tabindex');
|
|
198
|
+
if (stickyHeadingReady && !refreshStickyHeading) synchronizeStickyHeadingPosition();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const tableFitsLayout = (layout: TableLayout) => {
|
|
202
|
+
frame.dataset.tableLayout = layout;
|
|
203
|
+
return scrollRegion.scrollWidth - scrollRegion.clientWidth <= 1;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const updateLayout = () => {
|
|
207
|
+
scheduledFrame = null;
|
|
208
|
+
if (!isTopLevel || !table) {
|
|
209
|
+
frame.dataset.tableLayout = 'contained';
|
|
210
|
+
updateOverflow();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const candidates: TableLayout[] = ['prose', 'end', 'canvas'];
|
|
215
|
+
const selected = candidates.find(tableFitsLayout) ?? 'canvas';
|
|
216
|
+
frame.dataset.tableLayout = selected;
|
|
217
|
+
updateOverflow({ refreshStickyHeading: true });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const scheduleLayoutUpdate = () => {
|
|
221
|
+
if (scheduledFrame !== null) return;
|
|
222
|
+
scheduledFrame = requestAnimationFrame(updateLayout);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
frame.dataset.tableReady = 'true';
|
|
226
|
+
scrollRegion.addEventListener('scroll', () => updateOverflow(), { passive: true });
|
|
227
|
+
layoutUpdaters.add(scheduleLayoutUpdate);
|
|
228
|
+
if ('ResizeObserver' in window) {
|
|
229
|
+
const observer = new ResizeObserver(scheduleLayoutUpdate);
|
|
230
|
+
observer.observe(frame);
|
|
231
|
+
observer.observe(scrollRegion);
|
|
232
|
+
if (table) observer.observe(table);
|
|
233
|
+
if (frame.parentElement) observer.observe(frame.parentElement);
|
|
234
|
+
} else {
|
|
235
|
+
window.addEventListener('resize', scheduleLayoutUpdate, { passive: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
updateLayout();
|
|
239
|
+
document.fonts?.ready.then(scheduleLayoutUpdate).catch(() => {});
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const frames = Array.from(document.querySelectorAll<HTMLElement>('[data-table-frame]'));
|
|
243
|
+
frames.forEach(initializeTableFrame);
|
|
244
|
+
if (frames.length > 0) {
|
|
245
|
+
const preferenceObserver = new MutationObserver(() => layoutUpdaters.forEach((update) => update()));
|
|
246
|
+
preferenceObserver.observe(document.documentElement, {
|
|
247
|
+
attributeFilter: synchronizedAttributes,
|
|
248
|
+
attributes: true,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
</script>
|