@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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/astro.config.mjs +2 -0
  3. package/package.json +14 -2
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +56 -21
  6. package/schemas/content-frontmatter.schema.json +7 -7
  7. package/schemas/page-theme.schema.json +26 -26
  8. package/schemas/sitewide-content.schema.json +18 -18
  9. package/schemas/theme.schema.json +241 -241
  10. package/scripts/check-config.mjs +5 -1
  11. package/scripts/dev-local.mjs +106 -11
  12. package/scripts/init-site.mjs +2 -0
  13. package/scripts/lib/code-fence-metadata.mjs +11 -6
  14. package/scripts/lib/edit-source-link.mjs +53 -0
  15. package/scripts/lib/navigation-model.mjs +1 -1
  16. package/scripts/lib/norna-markdown-blocks.mjs +91 -0
  17. package/scripts/lib/project-config.mjs +42 -12
  18. package/scripts/lib/schema-definitions.mjs +10 -2
  19. package/scripts/lib/schema-editor-metadata.mjs +20 -3
  20. package/scripts/lib/schema-value-definitions.mjs +3 -0
  21. package/scripts/lib/site-paths.mjs +14 -2
  22. package/scripts/lib/table-render-plugin.mjs +33 -0
  23. package/src/components/CardList.astro +1 -0
  24. package/src/components/CodeBlockCopyScript.astro +2 -1
  25. package/src/components/ImageCarousel.astro +4 -1
  26. package/src/components/ImageStack.astro +34 -9
  27. package/src/components/ImageStackEnhancement.astro +331 -0
  28. package/src/components/PageContentsNavigation.astro +2 -3
  29. package/src/components/SectionNavigationScript.astro +45 -7
  30. package/src/components/SiteNavigation.astro +73 -49
  31. package/src/components/SitePage.astro +38 -23
  32. package/src/components/SiteTreeNavigation.astro +3 -0
  33. package/src/components/TableOverflowScript.astro +251 -0
  34. package/src/components/TreeNavigationScript.astro +78 -22
  35. package/src/layouts/BaseLayout.astro +31 -5
  36. package/src/lib/generatedImages.ts +18 -0
  37. package/src/lib/sectionContent.ts +4 -27
  38. package/src/lib/sitePages.ts +6 -3
  39. package/src/styles/content.css +298 -23
  40. package/src/styles/media.css +187 -3
  41. package/src/styles/navigation.css +168 -3
  42. package/src/styles/page-layout.css +48 -16
  43. 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
- {showMobileNavigation && <details class="mobile-nav-menu">
181
- <summary>
182
- <span>{projectConfig.locale.labels.navigationMenu}</span>
183
- </summary>
184
- <div class="mobile-nav-panel">
185
- <div class="mobile-site-nav" data-navigation-root={pageHref(homePage)}>
186
- {showSiteNavigation && (
187
- <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
188
- <h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
189
- <NavigationTreeControls
190
- idPrefix="mobile-tree-navigation"
191
- nodes={siteNavigationTree}
192
- />
193
- <NavigationPageTree
194
- nodes={siteNavigationTree}
195
- currentPage={currentPage}
196
- variant="mobile"
197
- integratePageSections={navigationMode === 'tree'}
198
- />
199
- </nav>
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
- {navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && currentPage && (
203
- <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.pageNavigation}>
204
- {!showSinglePageTopLink && (
205
- <h2 tabindex="-1">{projectConfig.locale.labels.pageNavigation}</h2>
206
- )}
207
- <ul class="mobile-nav-sections">
208
- {showSinglePageTopLink && (
209
- <li class="mobile-nav-page-top-item">
210
- <a
211
- class="mobile-nav-destination mobile-nav-page-top"
212
- data-page-top
213
- href={pageHref(currentPage)}
214
- >
215
- {currentPage.title}
216
- </a>
217
- </li>
218
- )}
219
- {currentPageNavigation.sections.map((section) => (
220
- <li>
221
- <a class="mobile-nav-destination" href={`#${section.id}`}>{section.title}</a>
222
- </li>
223
- ))}
224
- </ul>
225
- </nav>
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 { getEditSourceUrl } from '../../scripts/lib/edit-source-link.mjs';
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 editSourceUrl = projectConfig.editLink
112
- ? getEditSourceUrl({
113
- baseUrl: projectConfig.editLink.baseUrl,
114
- sourcePath: currentPage.contentLabel,
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 integratePageContentsInTree = pageContentsPlacement.placement === 'page-tree';
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 = navigationModel.requestedMode === 'tree'
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 = hasTreeNavigation || showBreadcrumbs;
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={hasContentsNavigation}
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': hasTreeNavigation,
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
- {hasTreeNavigation && (
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(editSourceUrl),
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
- {editSourceUrl && (
235
- <EditSourceLink href={editSourceUrl} label={projectConfig.locale.labels.editSource} />
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>