@janga/norna 0.7.23 → 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.
Files changed (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -2,21 +2,34 @@
2
2
  import { getCollection, type CollectionEntry } from 'astro:content';
3
3
  import BaseLayout from '../layouts/BaseLayout.astro';
4
4
  import CodeBlockCopyScript from './CodeBlockCopyScript.astro';
5
+ import EditSourceLink from './EditSourceLink.astro';
6
+ import ImageStackEnhancement from './ImageStackEnhancement.astro';
5
7
  import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
6
8
  import SiteNavigation from './SiteNavigation.astro';
7
9
  import SiteBanners from './SiteBanners.astro';
8
10
  import PageContentsNavigation from './PageContentsNavigation.astro';
11
+ import PageSequenceNavigation from './PageSequenceNavigation.astro';
9
12
  import SiteSection from './SiteSection.astro';
10
13
  import SiteTreeNavigation from './SiteTreeNavigation.astro';
14
+ import TableOverflowScript from './TableOverflowScript.astro';
11
15
  import TreeNavigationScript from './TreeNavigationScript.astro';
12
16
  import { getSectionsContent, type ResolvedSection } from '../lib/sectionContent';
13
17
  import { getPageTheme } from '../lib/pageThemes';
14
18
  import { getSiteModel, type SitePage } from '../lib/sitePages';
15
- import { flattenSiteNavigationTree, getListedSiteNavigationTree } from '../lib/siteNavigation';
19
+ import {
20
+ flattenSiteNavigationTree,
21
+ getDirectChildPages,
22
+ getListedSiteNavigationTree,
23
+ getSequentialPageNavigation,
24
+ } from '../lib/siteNavigation';
16
25
  import { getToday, isVisibleToday } from '../lib/visibility';
17
26
  import { getLogoAsset } from '../lib/sitePublicAssets';
18
- import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
27
+ import {
28
+ resolveNavigationModel,
29
+ resolvePageContentsPlacement,
30
+ } from '../../scripts/lib/navigation-model.mjs';
19
31
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
32
+ import { resolveEditSourceTarget } from '../../scripts/lib/edit-source-link.mjs';
20
33
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
21
34
  import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
22
35
 
@@ -55,24 +68,8 @@ const logo = logoAsset
55
68
  height: logoSettings?.height,
56
69
  }
57
70
  : undefined;
58
- const siteHtml = entry.rendered?.html ?? '';
59
- const resolvedSections = await getSectionsContent(siteHtml, currentPage);
60
- const hasCodeBlocks = resolvedSections.some((section) => section.contentBlocks.some((block) => (
61
- block.type === 'html' && /<pre\b[^>]*>[\s\S]*?<code\b/i.test(block.html)
62
- )));
63
71
  const today = getToday();
64
72
  const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
65
- const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
66
- block.type === 'image-stack'
67
- || block.type === 'image-carousel'
68
- ));
69
- const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
70
- let contentSectionIndex = 0;
71
- const renderedSections = resolvedSections.map((section, index) => ({
72
- section,
73
- sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
74
- prioritizeFirstImage: index === firstImageSectionIndex,
75
- }));
76
73
 
77
74
  const getPageContent = (page: SitePage) =>
78
75
  page.markdownDocument.navigationHeadings.map(({ depth, id, parentId, title }) => ({
@@ -92,7 +89,42 @@ const siteNavigation = siteNodes.map((node) => {
92
89
  .map(({ id, title }) => ({ id, title })),
93
90
  };
94
91
  });
95
- const renderedNavigationEntries = flattenSiteNavigationTree(getListedSiteNavigationTree(siteNavigation));
92
+ const listedNavigationTree = getListedSiteNavigationTree(siteNavigation);
93
+ const renderedNavigationEntries = flattenSiteNavigationTree(listedNavigationTree);
94
+ const pageSequence = getSequentialPageNavigation(listedNavigationTree, currentPage.pagePath);
95
+ const childPages = getDirectChildPages(listedNavigationTree, currentPage.pagePath);
96
+ const siteHtml = entry.rendered?.html ?? '';
97
+ const resolvedSections = await getSectionsContent(siteHtml, currentPage, childPages);
98
+ const hasCodeBlocks = resolvedSections.some((section) => section.contentBlocks.some((block) => (
99
+ block.type === 'html' && /<pre\b[^>]*>[\s\S]*?<code\b/i.test(block.html)
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
+ )));
107
+ const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
108
+ block.type === 'image-stack'
109
+ || block.type === 'image-carousel'
110
+ ));
111
+ const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
112
+ let contentSectionIndex = 0;
113
+ const renderedSections = resolvedSections.map((section, index) => ({
114
+ section,
115
+ sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
116
+ prioritizeFirstImage: index === firstImageSectionIndex,
117
+ }));
118
+ const hasPageSequence = Boolean(pageSequence.previous || pageSequence.next);
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,
126
+ })
127
+ : null;
96
128
  const navigationModel = resolveNavigationModel({
97
129
  mode: projectConfig.navigation.mode,
98
130
  nodes: renderedNavigationEntries.map(({ node, headings }) => ({
@@ -114,18 +146,39 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
114
146
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
115
147
  const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
116
148
  const hasTreeNavigation = navigationModel.mode === 'tree';
149
+ const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
117
150
  const currentPageNavigation = siteNavigation.find(({ node }) => (
118
151
  node.kind === 'page' && node.pathname === currentPage.pathname
119
152
  ));
120
153
  const pageContentsHeadings = currentPageNavigation?.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [];
121
- const hasContentsNavigation = hasTreeNavigation && pageContentsHeadings.length > 1;
122
- const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
154
+ const pageContentsPlacement = resolvePageContentsPlacement({
155
+ navigationMode: navigationModel.mode,
156
+ nodes: renderedNavigationEntries.map(({ node }) => ({
157
+ depth: Math.max(1, node.depth),
158
+ isHome: node.isHome,
159
+ listed: node.navigation.listed,
160
+ pagePath: node.pagePath,
161
+ })),
162
+ currentPage: {
163
+ isHome: currentPage.isHome,
164
+ pagePath: currentPage.pagePath,
165
+ },
166
+ headingCount: pageContentsHeadings.length,
167
+ });
168
+ const hasContentsNavigation = pageContentsPlacement.placement !== 'none';
169
+ const hasTrackedSectionNavigation = hasContentsNavigation
170
+ || (hasTreeNavigation && currentPage.isHome && pageContentsHeadings.length >= 2);
171
+ const integratePageContentsInTree = hasTreeNavigation;
172
+ const showContentsRail = pageContentsPlacement.placement === 'contents-rail';
173
+ const hasNavigationRailFrame = !currentPage.isHome && (
174
+ navigationModel.requestedMode === 'tree'
123
175
  || (
124
176
  navigationModel.requestedMode === 'automatic'
125
177
  && (navigationModel.hasNestedPages || navigationModel.hasCategories)
126
- );
178
+ )
179
+ );
127
180
  const showBreadcrumbs = !currentPage.isHome && currentPage.parentPagePath !== null;
128
- const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
181
+ const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
129
182
 
130
183
  ---
131
184
 
@@ -137,7 +190,8 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
137
190
  frameColors={frameColors}
138
191
  pathname={currentPage.pathname}
139
192
  footer={sitewideData.footer}
140
- contentsNavigation={hasContentsNavigation}
193
+ contentsNavigation={hasTrackedSectionNavigation}
194
+ searchable
141
195
  >
142
196
  <SiteNavigation
143
197
  logo={logo}
@@ -152,27 +206,31 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
152
206
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
153
207
 
154
208
  <div class:list={['site-page-layout', {
155
- 'site-page-layout-tree': hasTreeNavigation,
156
- 'site-page-layout-contents': hasContentsNavigation,
157
- }]}>
158
- {hasTreeNavigation && (
159
- <SiteTreeNavigation currentPage={currentPage} navigationEntries={siteNavigation} />
209
+ 'site-page-layout-tree': showTreeNavigation,
210
+ 'site-page-layout-contents': showContentsRail,
211
+ }]}
212
+ data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
213
+ >
214
+ {showTreeNavigation && (
215
+ <SiteTreeNavigation
216
+ currentPage={currentPage}
217
+ navigationEntries={siteNavigation}
218
+ integratePageSections={integratePageContentsInTree}
219
+ />
160
220
  )}
161
221
  <main
162
- class:list={['site-content', { 'site-content-has-breadcrumbs': reserveBreadcrumbSpace }]}
222
+ class:list={['site-content', {
223
+ 'site-content-has-breadcrumbs': reserveBreadcrumbSpace,
224
+ 'site-content-has-edit-source': Boolean(editSourceTarget),
225
+ 'site-content-has-page-sequence': hasPageSequence,
226
+ }]}
163
227
  id="main-content"
228
+ data-pagefind-body
164
229
  style={`--page-text-width: ${pageTextWidth}`}
165
230
  >
166
231
  {showBreadcrumbs
167
232
  ? <SiteBreadcrumbs currentPage={currentPage} nodes={siteNodes} />
168
233
  : reserveBreadcrumbSpace && <div class="site-breadcrumbs" aria-hidden="true"></div>}
169
- {hasContentsNavigation && (
170
- <PageContentsNavigation
171
- headings={pageContentsHeadings}
172
- pageTitle={currentPage.title}
173
- variant="inline"
174
- />
175
- )}
176
234
  {renderedSections.map(({ section, sectionIndex, prioritizeFirstImage }) => (
177
235
  <SiteSection
178
236
  section={section}
@@ -182,15 +240,25 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
182
240
  prioritizeFirstImage={prioritizeFirstImage}
183
241
  />
184
242
  ))}
243
+ {editSourceTarget && (
244
+ <EditSourceLink
245
+ href={editSourceTarget.href}
246
+ label={editSourceTarget.kind === 'local'
247
+ ? projectConfig.locale.labels.openInVsCode
248
+ : projectConfig.locale.labels.editSource}
249
+ />
250
+ )}
251
+ <PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
185
252
  </main>
186
- {hasContentsNavigation && (
253
+ {showContentsRail && (
187
254
  <PageContentsNavigation
188
255
  headings={pageContentsHeadings}
189
256
  pageTitle={currentPage.title}
190
- variant="rail"
191
257
  />
192
258
  )}
193
259
  </div>
194
260
  {hasTreeNavigation && <TreeNavigationScript />}
195
261
  {hasCodeBlocks && <CodeBlockCopyScript />}
262
+ {hasTables && <TableOverflowScript />}
263
+ {hasImageStack && <ImageStackEnhancement />}
196
264
  </BaseLayout>
@@ -9,6 +9,7 @@ import {
9
9
  import CardList from './CardList.astro';
10
10
  import ImageCarousel from './ImageCarousel.astro';
11
11
  import ImageStack from './ImageStack.astro';
12
+ import PageList from './PageList.astro';
12
13
  import type { ResolvedSection } from '../lib/sectionContent';
13
14
 
14
15
  type ResponsiveValue<T> = { desktop: T; mobile: T };
@@ -113,6 +114,7 @@ const isStructuredBlock = (block: ResolvedSection['contentBlocks'][number]) => (
113
114
  block.type === 'image-stack'
114
115
  || block.type === 'image-carousel'
115
116
  || block.type === 'card-list'
117
+ || block.type === 'page-list'
116
118
  );
117
119
  const hasMedia = section.contentBlocks.some((block) => (
118
120
  block.type === 'image-stack'
@@ -121,7 +123,7 @@ const hasMedia = section.contentBlocks.some((block) => (
121
123
  ));
122
124
  const contentGroups: Array<
123
125
  | { type: 'reading'; blocks: ResolvedSection['contentBlocks'] }
124
- | { type: 'media'; block: Extract<ResolvedSection['contentBlocks'][number], { type: 'image-stack' | 'image-carousel' | 'card-list' }> }
126
+ | { type: 'structured'; block: Extract<ResolvedSection['contentBlocks'][number], { type: 'image-stack' | 'image-carousel' | 'card-list' | 'page-list' }> }
125
127
  > = [];
126
128
  let readingBlocks: ResolvedSection['contentBlocks'] = [];
127
129
  const flushReadingBlocks = () => {
@@ -133,14 +135,17 @@ const flushReadingBlocks = () => {
133
135
  for (const block of section.contentBlocks) {
134
136
  if (isStructuredBlock(block)) {
135
137
  flushReadingBlocks();
136
- contentGroups.push({ type: 'media', block });
138
+ contentGroups.push({ type: 'structured', block });
137
139
  } else {
138
140
  readingBlocks = [...readingBlocks, block];
139
141
  }
140
142
  }
141
143
 
142
144
  flushReadingBlocks();
143
- const firstMediaGroupIndex = contentGroups.findIndex((group) => group.type === 'media');
145
+ const firstMediaGroupIndex = contentGroups.findIndex((group) => (
146
+ group.type === 'structured'
147
+ && (group.block.type === 'image-stack' || group.block.type === 'image-carousel')
148
+ ));
144
149
  const markdownHeadingStyle = (level: 'h3' | 'h4') => {
145
150
  const headingConfig = markdownHeadings[level];
146
151
  const fontSize = getHeadingTypographySizeValue(level, headingConfig.size);
@@ -240,6 +245,9 @@ const sectionStyle = [
240
245
  />
241
246
  );
242
247
  }
248
+ if (block.type === 'page-list') {
249
+ return <PageList pages={block.pages} />;
250
+ }
243
251
 
244
252
  return (
245
253
  <ImageStack
@@ -1,21 +1,25 @@
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';
7
+ import NavigationTreeControls from './NavigationTreeControls.astro';
6
8
 
7
9
  interface Props {
8
10
  currentPage: SitePage;
9
11
  navigationEntries: SiteNavigationEntry[];
12
+ integratePageSections?: boolean;
10
13
  }
11
14
 
12
- const { currentPage, navigationEntries } = Astro.props;
15
+ const { currentPage, navigationEntries, integratePageSections = false } = Astro.props;
13
16
  const roots = getListedSiteNavigationTree(navigationEntries);
14
17
  const activeRoot = roots.find((node) => (
15
18
  currentPage.pageDirectory === node.node.pageDirectory
16
19
  || currentPage.pageDirectory.startsWith(`${node.node.pageDirectory}/pages/`)
17
20
  ));
18
21
  const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}/` : '/';
22
+ const navigationStateRoot = withBasePath(projectConfig.site.basePath, '/');
19
23
  ---
20
24
 
21
25
  {activeRoot && (
@@ -23,12 +27,18 @@ const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}
23
27
  id="tree-local-navigation"
24
28
  class="tree-local-navigation"
25
29
  data-navigation-root={navigationRoot}
30
+ data-navigation-state-root={navigationStateRoot}
26
31
  >
32
+ <NavigationTreeControls
33
+ idPrefix="desktop-tree-navigation"
34
+ nodes={[activeRoot]}
35
+ />
27
36
  <nav aria-label={`${activeRoot.node.title}: ${projectConfig.locale.labels.siteNavigation}`}>
28
37
  <NavigationPageTree
29
38
  nodes={[activeRoot]}
30
39
  currentPage={currentPage}
31
40
  variant="sidebar"
41
+ integratePageSections={integratePageSections}
32
42
  />
33
43
  </nav>
34
44
  </aside>
@@ -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>