@janga/norna 0.7.25 → 0.7.26

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 (131) hide show
  1. package/README.md +3 -2
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/dev-local.mjs +24 -2
  16. package/scripts/generate-schemas.mjs +15 -3
  17. package/scripts/lib/category-destinations.mjs +48 -0
  18. package/scripts/lib/code-fence-metadata.mjs +14 -37
  19. package/scripts/lib/content-tabs.mjs +155 -0
  20. package/scripts/lib/details-headings.mjs +58 -0
  21. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  22. package/scripts/lib/editor-language-service.mjs +238 -116
  23. package/scripts/lib/locale-registry.mjs +597 -0
  24. package/scripts/lib/locales/az-Latn.mjs +78 -0
  25. package/scripts/lib/locales/be.mjs +78 -0
  26. package/scripts/lib/locales/bg.mjs +78 -0
  27. package/scripts/lib/locales/bs.mjs +78 -0
  28. package/scripts/lib/locales/ca.mjs +78 -0
  29. package/scripts/lib/locales/cnr.mjs +78 -0
  30. package/scripts/lib/locales/cs.mjs +78 -0
  31. package/scripts/lib/locales/da.mjs +78 -0
  32. package/scripts/lib/locales/de.mjs +78 -0
  33. package/scripts/lib/locales/el.mjs +78 -0
  34. package/scripts/lib/locales/en.mjs +78 -0
  35. package/scripts/lib/locales/es.mjs +78 -0
  36. package/scripts/lib/locales/et.mjs +78 -0
  37. package/scripts/lib/locales/fi.mjs +78 -0
  38. package/scripts/lib/locales/fil.mjs +78 -0
  39. package/scripts/lib/locales/fr.mjs +78 -0
  40. package/scripts/lib/locales/ga.mjs +78 -0
  41. package/scripts/lib/locales/ha.mjs +78 -0
  42. package/scripts/lib/locales/hr.mjs +78 -0
  43. package/scripts/lib/locales/hu.mjs +78 -0
  44. package/scripts/lib/locales/id.mjs +78 -0
  45. package/scripts/lib/locales/is.mjs +78 -0
  46. package/scripts/lib/locales/it.mjs +78 -0
  47. package/scripts/lib/locales/jv.mjs +78 -0
  48. package/scripts/lib/locales/lb.mjs +78 -0
  49. package/scripts/lib/locales/lt.mjs +78 -0
  50. package/scripts/lib/locales/lv.mjs +78 -0
  51. package/scripts/lib/locales/mk.mjs +78 -0
  52. package/scripts/lib/locales/mt.mjs +78 -0
  53. package/scripts/lib/locales/nb.mjs +78 -0
  54. package/scripts/lib/locales/nl.mjs +78 -0
  55. package/scripts/lib/locales/nn.mjs +78 -0
  56. package/scripts/lib/locales/pcm.mjs +78 -0
  57. package/scripts/lib/locales/pl.mjs +78 -0
  58. package/scripts/lib/locales/pt.mjs +78 -0
  59. package/scripts/lib/locales/ro.mjs +78 -0
  60. package/scripts/lib/locales/ru.mjs +78 -0
  61. package/scripts/lib/locales/sk.mjs +78 -0
  62. package/scripts/lib/locales/sl.mjs +78 -0
  63. package/scripts/lib/locales/sq.mjs +78 -0
  64. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  65. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  66. package/scripts/lib/locales/sv.mjs +78 -0
  67. package/scripts/lib/locales/sw.mjs +78 -0
  68. package/scripts/lib/locales/tr.mjs +78 -0
  69. package/scripts/lib/locales/uk.mjs +78 -0
  70. package/scripts/lib/locales/vi.mjs +78 -0
  71. package/scripts/lib/markdown-links.mjs +4 -25
  72. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  73. package/scripts/lib/markdown-notes.mjs +165 -0
  74. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  75. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  76. package/scripts/lib/page-aliases.mjs +12 -1
  77. package/scripts/lib/page-markdown.mjs +63 -16
  78. package/scripts/lib/page-move-plan.mjs +22 -5
  79. package/scripts/lib/pagefind-translations.mjs +620 -0
  80. package/scripts/lib/presentation-contract.mjs +6 -1
  81. package/scripts/lib/presentation.mjs +29 -30
  82. package/scripts/lib/project-config.mjs +2 -173
  83. package/scripts/lib/schema-definitions.mjs +3 -10
  84. package/scripts/lib/schema-editor-metadata.mjs +22 -43
  85. package/scripts/lib/schema-value-definitions.mjs +4 -8
  86. package/scripts/lib/semantic-callouts.mjs +4 -2
  87. package/scripts/lib/site-content.mjs +4 -2
  88. package/scripts/lib/site-link-graph.mjs +16 -6
  89. package/scripts/lib/site-navigation-tree.mjs +0 -11
  90. package/scripts/lib/sitemap.mjs +5 -3
  91. package/scripts/lib/structured-blocks.mjs +108 -0
  92. package/scripts/lib/table-render-plugin.mjs +46 -3
  93. package/scripts/lib/table-row-headers.mjs +218 -0
  94. package/scripts/lib/theme-presets.mjs +3 -10
  95. package/scripts/lib/yaml-config.mjs +2 -6
  96. package/scripts/migrate-check.mjs +19 -0
  97. package/scripts/sync-content-sections.mjs +39 -10
  98. package/scripts/sync-site-public.mjs +7 -1
  99. package/src/components/CodeBlockCopyScript.astro +47 -5
  100. package/src/components/ContentBlocks.astro +39 -0
  101. package/src/components/ContentTabsScript.astro +60 -0
  102. package/src/components/DisplaySettings.astro +17 -19
  103. package/src/components/NavigationPageTree.astro +1 -1
  104. package/src/components/NoteNavigationScript.astro +4 -0
  105. package/src/components/PageAliasRedirect.astro +5 -4
  106. package/src/components/SearchNavigationScript.astro +4 -0
  107. package/src/components/SearchPage.astro +43 -2
  108. package/src/components/SectionNavigationScript.astro +43 -11
  109. package/src/components/SiteBreadcrumbs.astro +1 -1
  110. package/src/components/SiteNavigation.astro +29 -20
  111. package/src/components/SitePage.astro +40 -15
  112. package/src/components/SiteSection.astro +5 -81
  113. package/src/components/SiteTreeNavigation.astro +2 -2
  114. package/src/components/TableOverflowScript.astro +386 -58
  115. package/src/components/TopPageMenu.astro +47 -0
  116. package/src/components/TreeNavigationScript.astro +10 -3
  117. package/src/layouts/BaseLayout.astro +8 -12
  118. package/src/lib/navigationFollowing.ts +142 -0
  119. package/src/lib/noteNavigation.ts +50 -0
  120. package/src/lib/searchNavigation.ts +107 -0
  121. package/src/lib/sectionContent.ts +20 -90
  122. package/src/lib/siteNavigation.ts +0 -5
  123. package/src/lib/sitePages.ts +13 -2
  124. package/src/lib/table-sort-tooltip.ts +109 -0
  125. package/src/pages/[...slug].astro +16 -6
  126. package/src/styles/content.css +394 -132
  127. package/src/styles/foundations.css +9 -0
  128. package/src/styles/media.css +5 -0
  129. package/src/styles/navigation.css +63 -74
  130. package/src/styles/page-layout.css +7 -0
  131. package/src/styles/responsive.css +2 -7
@@ -1,38 +1,48 @@
1
1
  ---
2
- import { ChevronLeft, ChevronRight } from '@lucide/astro';
2
+ import { ChevronDown, ChevronsUpDown, ChevronUp } from '@lucide/astro';
3
3
  import projectConfig from '../../scripts/lib/project-config.mjs';
4
4
 
5
5
  const labels = projectConfig.locale.labels;
6
6
  ---
7
7
 
8
8
  <template data-table-navigation-template>
9
- <div class="norna-table-navigation" data-table-navigation hidden>
9
+ <div class="norna-table-navigation" data-table-navigation data-table-locale={projectConfig.locale.formattingLocale} hidden>
10
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>
11
+ <div class="norna-table-scrollbar" data-table-scrollbar role="scrollbar"
12
+ aria-label={labels.tableColumns} aria-orientation="horizontal"
13
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
14
+ <span class="norna-table-scrollbar-thumb" data-table-scrollbar-thumb aria-hidden="true"></span>
28
15
  </div>
29
16
  </div>
30
17
  </template>
31
18
 
19
+ <template data-table-sort-indicator-template
20
+ data-sort-ascending={labels.tableSortAscending}
21
+ data-sort-descending={labels.tableSortDescending}
22
+ data-sort-reset={labels.tableSortReset}>
23
+ <span class="norna-table-sort-indicator" aria-hidden="true">
24
+ <span data-table-sort-icon="unsorted"><ChevronsUpDown size={15} stroke-width={1.8} /></span>
25
+ <span data-table-sort-icon="ascending"><ChevronUp size={15} stroke-width={1.8} /></span>
26
+ <span data-table-sort-icon="descending"><ChevronDown size={15} stroke-width={1.8} /></span>
27
+ </span>
28
+ </template>
29
+
32
30
  <script>
31
+ import { createTableSortTooltip } from '../lib/table-sort-tooltip';
32
+
33
33
  type TableLayout = 'contained' | 'prose' | 'end' | 'canvas';
34
+ type SortDirection = 'ascending' | 'descending';
35
+ type ScrollbarControl = { element: HTMLElement; thumb: HTMLElement; navigation: HTMLElement };
34
36
  const layoutUpdaters = new Set<() => void>();
37
+ let sortTooltip: ReturnType<typeof createTableSortTooltip> | undefined;
35
38
  const navigationTemplate = document.querySelector<HTMLTemplateElement>('[data-table-navigation-template]');
39
+ const sortIndicatorTemplate = document.querySelector<HTMLTemplateElement>('[data-table-sort-indicator-template]');
40
+ const sortActions = {
41
+ unsorted: sortIndicatorTemplate?.dataset.sortAscending ?? '',
42
+ ascending: sortIndicatorTemplate?.dataset.sortDescending ?? '',
43
+ descending: sortIndicatorTemplate?.dataset.sortReset ?? '',
44
+ };
45
+ const tableLocale = navigationTemplate?.content.querySelector<HTMLElement>('[data-table-locale]')?.dataset.tableLocale ?? 'en';
36
46
  const synchronizedAttributes = [
37
47
  'data-appearance',
38
48
  'data-focus-reading',
@@ -47,13 +57,25 @@ const labels = projectConfig.locale.labels;
47
57
  const isTopLevel = frame.parentElement?.classList.contains('section-markdown') === true;
48
58
  const tableHead = table?.tHead;
49
59
  const headingCells = Array.from(tableHead?.querySelectorAll<HTMLTableCellElement>('th') ?? []);
60
+ const hasRowHeaders = frame.dataset.tableRowHeaders === 'true';
50
61
  let scheduledFrame: number | null = null;
51
62
  let stickyHeading: HTMLElement | null = null;
52
63
  let stickyHeadingTrack: HTMLElement | null = null;
53
- let tableNavigation: HTMLElement | null = null;
54
- let previousButton: HTMLButtonElement | null = null;
55
- let nextButton: HTMLButtonElement | null = null;
64
+ const scrollbars: ScrollbarControl[] = [];
65
+ let scrollbarDrag: { control: ScrollbarControl; pointerId: number; grabOffset: number } | null = null;
56
66
  let overflowDescriptionId: string | null = null;
67
+ const tableBody = table?.tBodies.length === 1 ? table.tBodies[0] : null;
68
+ const bodyRows = Array.from(tableBody?.rows ?? []);
69
+ const originalRows = bodyRows.map((row, index) => ({ row, index }));
70
+ let sortedColumn: number | null = null;
71
+ let sortDirection: SortDirection | null = null;
72
+ const sortButtons: HTMLButtonElement[] = [];
73
+ const stickyCells: HTMLElement[] = [];
74
+ const stickySortButtons: HTMLButtonElement[] = [];
75
+ // One sticky surface keeps the heading and scrollbar together at both table edges.
76
+ const stickyContext = document.createElement('div');
77
+ stickyContext.className = 'norna-table-sticky-context';
78
+ frame.insertBefore(stickyContext, scrollRegion);
57
79
 
58
80
  const removeDuplicateIdentity = (element: HTMLElement) => {
59
81
  element.removeAttribute('id');
@@ -72,21 +94,190 @@ const labels = projectConfig.locale.labels;
72
94
  stickyHeadingTrack = document.createElement('div');
73
95
  stickyHeadingTrack.className = 'norna-table-sticky-heading-track';
74
96
  stickyHeading.append(stickyHeadingTrack);
75
- frame.insertBefore(stickyHeading, scrollRegion);
97
+ stickyContext.prepend(stickyHeading);
76
98
  };
77
99
 
78
100
  const createTableNavigation = () => {
79
101
  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]');
102
+ if (!scrollRegion.id) scrollRegion.id = `norna-table-scroll-${frameIndex + 1}`;
87
103
  overflowDescriptionId = `norna-table-overflow-${frameIndex + 1}`;
88
- if (description) description.id = overflowDescriptionId;
89
- frame.insertBefore(fragment, scrollRegion);
104
+ for (const placement of ['top', 'bottom']) {
105
+ const fragment = navigationTemplate.content.cloneNode(true) as DocumentFragment;
106
+ const navigation = fragment.querySelector<HTMLElement>('[data-table-navigation]');
107
+ const element = navigation?.querySelector<HTMLElement>('[data-table-scrollbar]');
108
+ const thumb = element?.querySelector<HTMLElement>('[data-table-scrollbar-thumb]');
109
+ if (!navigation || !element || !thumb) continue;
110
+ navigation.dataset.tableNavigation = placement;
111
+ element.setAttribute('aria-controls', scrollRegion.id);
112
+ const description = navigation.querySelector<HTMLElement>('[data-table-overflow-description]');
113
+ if (placement === 'top' && description) description.id = overflowDescriptionId;
114
+ else description?.remove();
115
+ scrollbars.push({ element, thumb, navigation });
116
+ if (placement === 'top') stickyContext.append(fragment);
117
+ else frame.append(fragment);
118
+ }
119
+ if (scrollbars.length === 2) frame.dataset.tableCustomScrollbars = 'true';
120
+ };
121
+
122
+ const cellText = (cell: HTMLTableCellElement) => (cell.textContent ?? '').replace(/\s+/gu, ' ').trim();
123
+
124
+ const parseNumber = (value: string) => {
125
+ if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(value)) return null;
126
+ const number = Number(value);
127
+ return Number.isFinite(number) ? number : null;
128
+ };
129
+
130
+ const parseIsoDate = (value: string) => {
131
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value);
132
+ if (dateOnly) {
133
+ const year = Number(dateOnly[1]);
134
+ const month = Number(dateOnly[2]);
135
+ const day = Number(dateOnly[3]);
136
+ const timestamp = Date.UTC(year, month - 1, day);
137
+ const date = new Date(timestamp);
138
+ return date.getUTCFullYear() === year
139
+ && date.getUTCMonth() === month - 1
140
+ && date.getUTCDate() === day
141
+ ? timestamp
142
+ : null;
143
+ }
144
+
145
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/u.test(value)) {
146
+ return null;
147
+ }
148
+
149
+ const timestamp = Date.parse(value);
150
+ return Number.isNaN(timestamp) ? null : timestamp;
151
+ };
152
+
153
+ const getColumnType = (columnIndex: number) => {
154
+ const values = originalRows
155
+ .map(({ row }) => cellText(row.cells[columnIndex]))
156
+ .filter((value) => value !== '');
157
+ if (values.length === 0) return 'text';
158
+ if (values.every((value) => parseNumber(value) !== null)) return 'number';
159
+ if (values.every((value) => parseIsoDate(value) !== null)) return 'date';
160
+ return 'text';
161
+ };
162
+
163
+ const getCollator = () => {
164
+ try {
165
+ return new Intl.Collator(tableLocale, {
166
+ numeric: false,
167
+ sensitivity: 'variant',
168
+ usage: 'sort',
169
+ });
170
+ } catch {
171
+ return new Intl.Collator(undefined, {
172
+ numeric: false,
173
+ sensitivity: 'variant',
174
+ usage: 'sort',
175
+ });
176
+ }
177
+ };
178
+
179
+ const isSortableTable = () => {
180
+ if (!tableBody || bodyRows.length === 0 || headingCells.length === 0 || tableHead?.rows.length !== 1) return false;
181
+ const expectedColumns = headingCells.length;
182
+ return headingCells.every((cell) => cell.colSpan === 1 && cell.rowSpan === 1
183
+ && !cell.querySelector('a, button, input, select, textarea, summary, [tabindex], [contenteditable]'))
184
+ && bodyRows.every((row) => row.cells.length === expectedColumns
185
+ && Array.from(row.cells).every((cell) => cell.colSpan === 1 && cell.rowSpan === 1));
186
+ };
187
+
188
+ const updateSortState = (activeColumn: number | null, direction: SortDirection | null) => {
189
+ headingCells.forEach((headingCell, columnIndex) => {
190
+ headingCell.removeAttribute('aria-sort');
191
+ const button = sortButtons[columnIndex];
192
+ if (!button) return;
193
+ const state = columnIndex === activeColumn && direction ? direction : 'unsorted';
194
+ const action = sortActions[state];
195
+ const columnName = headingCell.getAttribute('aria-label') ?? cellText(headingCell);
196
+ for (const control of [button, stickySortButtons[columnIndex]]) {
197
+ if (!control) continue;
198
+ control.dataset.tableSortState = state;
199
+ control.dataset.tableSortAction = action;
200
+ control.setAttribute('aria-label', `${columnName}: ${action}`);
201
+ sortTooltip?.refresh(control);
202
+ }
203
+ if (columnIndex === activeColumn && direction) headingCell.setAttribute('aria-sort', direction);
204
+ });
205
+ if (activeColumn === null || !direction) {
206
+ table?.removeAttribute('data-table-sort-column');
207
+ table?.removeAttribute('data-table-sort-direction');
208
+ } else {
209
+ table?.setAttribute('data-table-sort-column', String(activeColumn));
210
+ table?.setAttribute('data-table-sort-direction', direction);
211
+ }
212
+ };
213
+
214
+ const sortRows = (columnIndex: number, direction: SortDirection) => {
215
+ if (!tableBody) return;
216
+ const type = getColumnType(columnIndex);
217
+ const collator = type === 'text' ? getCollator() : null;
218
+ const sortedRows = [...originalRows].sort((left, right) => {
219
+ const leftValue = cellText(left.row.cells[columnIndex]);
220
+ const rightValue = cellText(right.row.cells[columnIndex]);
221
+ if (leftValue === '' || rightValue === '') {
222
+ if (leftValue === rightValue) return left.index - right.index;
223
+ return leftValue === '' ? 1 : -1;
224
+ }
225
+
226
+ let result = 0;
227
+ if (type === 'number') {
228
+ result = (parseNumber(leftValue) ?? 0) - (parseNumber(rightValue) ?? 0);
229
+ } else if (type === 'date') {
230
+ result = (parseIsoDate(leftValue) ?? 0) - (parseIsoDate(rightValue) ?? 0);
231
+ } else {
232
+ result = collator?.compare(leftValue, rightValue) ?? 0;
233
+ }
234
+
235
+ return result === 0
236
+ ? left.index - right.index
237
+ : result * (direction === 'ascending' ? 1 : -1);
238
+ });
239
+ sortedRows.forEach(({ row }) => tableBody.append(row));
240
+ };
241
+
242
+ const resetRows = () => {
243
+ if (!tableBody) return;
244
+ originalRows.forEach(({ row }) => tableBody.append(row));
245
+ };
246
+
247
+ const createTableSorting = () => {
248
+ if (!table || !sortIndicatorTemplate || !isSortableTable()) return;
249
+ sortTooltip ??= createTableSortTooltip();
250
+ headingCells.forEach((headingCell, columnIndex) => {
251
+ const button = document.createElement('button');
252
+ button.type = 'button';
253
+ button.className = 'norna-table-sort-button';
254
+ button.dataset.tableSortButton = '';
255
+ button.dataset.tableSortState = 'unsorted';
256
+ // Keep the semantic column name when its original control is hidden.
257
+ if (!headingCell.hasAttribute('aria-label')) headingCell.setAttribute('aria-label', cellText(headingCell));
258
+ button.append(...Array.from(headingCell.childNodes));
259
+ button.append(sortIndicatorTemplate.content.cloneNode(true));
260
+ headingCell.replaceChildren(button);
261
+ sortButtons.push(button);
262
+ sortTooltip.attach(button);
263
+ button.addEventListener('click', () => {
264
+ if (sortedColumn !== columnIndex) {
265
+ sortedColumn = columnIndex;
266
+ sortDirection = 'ascending';
267
+ sortRows(columnIndex, sortDirection);
268
+ } else if (sortDirection === 'ascending') {
269
+ sortDirection = 'descending';
270
+ sortRows(columnIndex, sortDirection);
271
+ } else {
272
+ resetRows();
273
+ sortedColumn = null;
274
+ sortDirection = null;
275
+ }
276
+ updateSortState(sortedColumn, sortDirection);
277
+ });
278
+ });
279
+ updateSortState(null, null);
280
+ table.dataset.tableSortable = 'true';
90
281
  };
91
282
 
92
283
  const setOverflowDescription = (enabled: boolean) => {
@@ -100,21 +291,83 @@ const labels = projectConfig.locale.labels;
100
291
  else scrollRegion.removeAttribute('aria-describedby');
101
292
  };
102
293
 
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
- });
294
+ const getScrollbarGeometry = (control = scrollbars[0]) => {
295
+ const maximum = Math.max(0, scrollRegion.scrollWidth - scrollRegion.clientWidth);
296
+ const rtl = getComputedStyle(scrollRegion).direction === 'rtl';
297
+ const position = Math.min(maximum, Math.max(0, scrollRegion.scrollLeft * (rtl ? -1 : 1)));
298
+ const width = control?.element.getBoundingClientRect().width ?? 0;
299
+ const fraction = scrollRegion.clientWidth / Math.max(1, scrollRegion.scrollWidth);
300
+ const thumbWidth = Math.min(width, Math.max(24, width * fraction));
301
+ return { maximum, rtl, position, width, thumbWidth, travel: width - thumbWidth };
302
+ };
303
+
304
+ const setScrollbarPosition = (position: number) => {
305
+ const { maximum, rtl } = getScrollbarGeometry();
306
+ scrollRegion.scrollTo({ left: Math.min(maximum, Math.max(0, position)) * (rtl ? -1 : 1), behavior: 'instant' });
307
+ updateOverflow();
308
+ };
309
+
310
+ const synchronizeScrollbar = () => {
311
+ for (const control of scrollbars) {
312
+ const { maximum, rtl, position, thumbWidth, travel } = getScrollbarGeometry(control);
313
+ const progress = maximum > 0 ? position / maximum : 0;
314
+ control.thumb.style.width = `${thumbWidth}px`;
315
+ control.thumb.style.left = `${travel * (rtl ? 1 - progress : progress)}px`;
316
+ control.element.setAttribute('aria-valuenow', String(Math.round(progress * 100)));
317
+ }
318
+ };
319
+
320
+ const dragScrollbar = (clientX: number) => {
321
+ if (!scrollbarDrag) return;
322
+ const { control } = scrollbarDrag;
323
+ const { maximum, rtl, travel } = getScrollbarGeometry(control);
324
+ if (travel <= 0) return;
325
+ const offset = clientX - control.element.getBoundingClientRect().left - scrollbarDrag.grabOffset;
326
+ const fraction = Math.min(1, Math.max(0, offset / travel));
327
+ setScrollbarPosition(maximum * (rtl ? 1 - fraction : fraction));
111
328
  };
112
329
 
113
330
  const synchronizeStickyHeadingPosition = () => {
114
331
  if (!stickyHeading || !stickyHeadingTrack || !table) return;
115
332
  const layerBounds = stickyHeading.getBoundingClientRect();
116
333
  const tableBounds = table.getBoundingClientRect();
117
- stickyHeadingTrack.style.transform = `translateX(${tableBounds.left - layerBounds.left}px)`;
334
+ const trackOffset = tableBounds.left - layerBounds.left;
335
+ stickyHeadingTrack.style.transform = `translateX(${trackOffset}px)`;
336
+
337
+ const rowHeader = stickyHeadingTrack.querySelector<HTMLElement>('[data-table-sticky-row-header]');
338
+ if (!rowHeader) return;
339
+ const rowHeaderLeft = Number.parseFloat(rowHeader.style.left) || 0;
340
+ const rowHeaderWidth = Number.parseFloat(rowHeader.style.width) || 0;
341
+ const isRightToLeft = getComputedStyle(table).direction === 'rtl';
342
+ const naturalLeft = tableBounds.left + rowHeaderLeft;
343
+ const desiredLeft = isRightToLeft
344
+ ? layerBounds.right - rowHeaderWidth
345
+ : layerBounds.left;
346
+ rowHeader.style.transform = `translateX(${desiredLeft - naturalLeft}px)`;
347
+ };
348
+
349
+ const synchronizeSortControls = (useSticky: boolean) => {
350
+ if (!stickyHeading || sortButtons.length === 0) return;
351
+ const active = document.activeElement;
352
+ const from = useSticky ? sortButtons : stickySortButtons;
353
+ const to = useSticky ? stickySortButtons : sortButtons;
354
+ const focusedColumn = from.findIndex((button) => button === active);
355
+ stickyHeading.toggleAttribute('inert', !useSticky);
356
+ if (useSticky) {
357
+ stickyHeading.removeAttribute('aria-hidden');
358
+ stickyHeading.setAttribute('role', 'group');
359
+ const groupLabel = scrollbars[0]?.element.getAttribute('aria-label');
360
+ if (groupLabel) stickyHeading.setAttribute('aria-label', groupLabel);
361
+ }
362
+ for (const [index, button] of sortButtons.entries()) {
363
+ button.tabIndex = useSticky ? -1 : 0;
364
+ if (!useSticky) button.removeAttribute('aria-hidden');
365
+ if (stickySortButtons[index]) stickySortButtons[index].tabIndex = useSticky ? 0 : -1;
366
+ }
367
+ // Transfer focus before hiding the control that previously owned it.
368
+ if (focusedColumn >= 0) to[focusedColumn]?.focus({ preventScroll: true });
369
+ if (useSticky) sortButtons.forEach((button) => button.setAttribute('aria-hidden', 'true'));
370
+ else stickyHeading.setAttribute('aria-hidden', 'true');
118
371
  };
119
372
 
120
373
  const synchronizeStickyHeadingGeometry = () => {
@@ -128,10 +381,40 @@ const labels = projectConfig.locale.labels;
128
381
  frame.style.setProperty('--table-sticky-heading-height', `${headingBounds.height}px`);
129
382
  stickyHeadingTrack.style.width = `${tableBounds.width}px`;
130
383
  stickyHeadingTrack.style.height = `${headingBounds.height}px`;
131
- stickyHeadingTrack.replaceChildren(...headingCells.map((headingCell) => {
384
+ headingCells.forEach((headingCell, columnIndex) => {
132
385
  const cellBounds = headingCell.getBoundingClientRect();
133
386
  const computedStyle = getComputedStyle(headingCell);
134
- const visualCell = document.createElement('div');
387
+ let visualCell = stickyCells[columnIndex];
388
+ if (!visualCell) {
389
+ visualCell = document.createElement('div');
390
+ visualCell.append(...Array.from(headingCell.childNodes, (node) => node.cloneNode(true)));
391
+ removeDuplicateIdentity(visualCell);
392
+ const button = visualCell.querySelector<HTMLButtonElement>('[data-table-sort-button]');
393
+ if (button) {
394
+ button.removeAttribute('aria-hidden');
395
+ button.tabIndex = -1;
396
+ sortTooltip?.attach(button);
397
+ button.addEventListener('click', () => sortButtons[columnIndex].click());
398
+ // Focus on a clipped column must reveal it in the real scroll region.
399
+ button.addEventListener('focus', () => {
400
+ if (!stickyHeading) return;
401
+ const bounds = visualCell.getBoundingClientRect();
402
+ const viewport = stickyHeading.getBoundingClientRect();
403
+ const pinnedWidth = hasRowHeaders && columnIndex > 0
404
+ ? stickyCells[0].getBoundingClientRect().width : 0;
405
+ const rtl = getComputedStyle(scrollRegion).direction === 'rtl';
406
+ const start = viewport.left + (rtl ? 0 : pinnedWidth);
407
+ const end = viewport.right - (rtl ? pinnedWidth : 0);
408
+ const delta = bounds.left < start ? bounds.left - start
409
+ : bounds.right > end ? Math.min(bounds.right - end, bounds.left - start) : 0;
410
+ if (Math.abs(delta) > 1) scrollRegion.scrollLeft += delta;
411
+ synchronizeStickyHeadingPosition();
412
+ });
413
+ stickySortButtons[columnIndex] = button;
414
+ }
415
+ stickyCells[columnIndex] = visualCell;
416
+ stickyHeadingTrack.append(visualCell);
417
+ }
135
418
  visualCell.className = 'norna-table-sticky-heading-cell';
136
419
  visualCell.style.left = `${cellBounds.left - tableBounds.left}px`;
137
420
  visualCell.style.top = `${cellBounds.top - headingBounds.top}px`;
@@ -147,18 +430,62 @@ const labels = projectConfig.locale.labels;
147
430
  visualCell.style.whiteSpace = computedStyle.whiteSpace;
148
431
  visualCell.style.wordBreak = computedStyle.wordBreak;
149
432
  visualCell.style.overflowWrap = computedStyle.overflowWrap;
150
- visualCell.append(...Array.from(headingCell.childNodes, (node) => node.cloneNode(true)));
151
- removeDuplicateIdentity(visualCell);
152
- return visualCell;
153
- }));
433
+ if (hasRowHeaders && columnIndex === 0) {
434
+ visualCell.classList.add('norna-table-sticky-heading-cell-row-header');
435
+ visualCell.dataset.tableStickyRowHeader = '';
436
+ }
437
+ });
154
438
  synchronizeStickyHeadingPosition();
155
439
  return true;
156
440
  };
157
441
 
442
+ createTableSorting();
158
443
  createTableNavigation();
159
444
  createStickyHeading();
160
- previousButton?.addEventListener('click', () => scrollTable(-1));
161
- nextButton?.addEventListener('click', () => scrollTable(1));
445
+ const stopScrollbarDrag = () => {
446
+ const pointerId = scrollbarDrag?.pointerId;
447
+ const scrollbar = scrollbarDrag?.control.element;
448
+ scrollbarDrag = null;
449
+ if (pointerId !== undefined && scrollbar?.hasPointerCapture(pointerId)) scrollbar.releasePointerCapture(pointerId);
450
+ if (scrollbar) delete scrollbar.dataset.dragging;
451
+ };
452
+ for (const control of scrollbars) {
453
+ const { element: scrollbar, thumb: scrollbarThumb } = control;
454
+ scrollbar.addEventListener('keydown', (event) => {
455
+ const { maximum, rtl, position } = getScrollbarGeometry(control);
456
+ const step = Math.max(1, scrollRegion.clientWidth * 0.1);
457
+ const page = scrollRegion.clientWidth * 0.8;
458
+ let next: number;
459
+ switch (event.key) {
460
+ case 'ArrowLeft': next = position + step * (rtl ? 1 : -1); break;
461
+ case 'ArrowRight': next = position + step * (rtl ? -1 : 1); break;
462
+ case 'PageUp': next = position - page; break;
463
+ case 'PageDown': next = position + page; break;
464
+ case 'Home': next = 0; break;
465
+ case 'End': next = maximum; break;
466
+ default: return;
467
+ }
468
+ event.preventDefault();
469
+ setScrollbarPosition(next);
470
+ });
471
+ scrollbar.addEventListener('pointerdown', (event) => {
472
+ if (!event.isPrimary || event.button !== 0) return;
473
+ event.preventDefault();
474
+ scrollbar.focus({ preventScroll: true });
475
+ const bounds = scrollbarThumb.getBoundingClientRect();
476
+ const onThumb = event.clientX >= bounds.left && event.clientX <= bounds.right;
477
+ scrollbarDrag = { control, pointerId: event.pointerId, grabOffset: onThumb ? event.clientX - bounds.left : bounds.width / 2 };
478
+ scrollbar.setPointerCapture(event.pointerId);
479
+ scrollbar.dataset.dragging = 'true';
480
+ dragScrollbar(event.clientX);
481
+ });
482
+ scrollbar.addEventListener('pointermove', (event) => {
483
+ if (scrollbarDrag?.pointerId === event.pointerId) dragScrollbar(event.clientX);
484
+ });
485
+ scrollbar.addEventListener('pointerup', stopScrollbarDrag);
486
+ scrollbar.addEventListener('pointercancel', stopScrollbarDrag);
487
+ scrollbar.addEventListener('lostpointercapture', stopScrollbarDrag);
488
+ }
162
489
 
163
490
  const updateOverflow = ({ refreshStickyHeading = false } = {}) => {
164
491
  const maximumScroll = Math.max(0, scrollRegion.scrollWidth - scrollRegion.clientWidth);
@@ -173,12 +500,13 @@ const labels = projectConfig.locale.labels;
173
500
  ? tableBounds.left >= scrollBounds.left - 2
174
501
  : tableBounds.right <= scrollBounds.right + 2);
175
502
 
176
- if (tableNavigation) {
177
- tableNavigation.hidden = !hasOverflow;
178
- frame.style.setProperty(
179
- '--table-navigation-height',
180
- hasOverflow ? `${tableNavigation.getBoundingClientRect().height}px` : '0px',
181
- );
503
+ for (const { navigation } of scrollbars) {
504
+ if (!hasOverflow && navigation.contains(document.activeElement)) {
505
+ scrollRegion.tabIndex = -1;
506
+ scrollRegion.focus({ preventScroll: true });
507
+ }
508
+ if (!hasOverflow) stopScrollbarDrag();
509
+ navigation.hidden = !hasOverflow;
182
510
  }
183
511
  const stickyHeadingReady = hasOverflow && stickyHeading
184
512
  ? (refreshStickyHeading
@@ -190,11 +518,11 @@ const labels = projectConfig.locale.labels;
190
518
  frame.dataset.tableAtStart = isAtStart ? 'true' : 'false';
191
519
  frame.dataset.tableAtEnd = isAtEnd ? 'true' : 'false';
192
520
  frame.dataset.tableStickyHeading = stickyHeadingReady ? 'true' : 'false';
193
- if (previousButton) previousButton.disabled = isAtStart;
194
- if (nextButton) nextButton.disabled = isAtEnd;
521
+ synchronizeSortControls(Boolean(stickyHeadingReady));
522
+ if (hasOverflow) synchronizeScrollbar();
195
523
  setOverflowDescription(hasOverflow);
196
524
  if (hasOverflow) scrollRegion.setAttribute('tabindex', '0');
197
- else scrollRegion.removeAttribute('tabindex');
525
+ else if (document.activeElement !== scrollRegion) scrollRegion.removeAttribute('tabindex');
198
526
  if (stickyHeadingReady && !refreshStickyHeading) synchronizeStickyHeadingPosition();
199
527
  };
200
528
 
@@ -0,0 +1,47 @@
1
+ ---
2
+ import { ChevronDown } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+ import type { SiteNavigationNode } from '../lib/siteNavigation';
6
+ import type { SiteNode } from '../lib/sitePages';
7
+ import NavigationPageTree from './NavigationPageTree.astro';
8
+
9
+ interface Props {
10
+ node: SiteNavigationNode;
11
+ currentPage?: SiteNode;
12
+ showSections: boolean;
13
+ showChildren: boolean;
14
+ }
15
+ const { node, currentPage, showSections, showChildren } = Astro.props;
16
+ const sections = showSections ? node.sections : [];
17
+ const children = showChildren ? node.children : [];
18
+ const pageHref = node.node.kind === 'page'
19
+ ? withBasePath(projectConfig.site.basePath, node.node.pathname)
20
+ : '';
21
+ const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}`;
22
+ ---
23
+
24
+ {(sections.length > 0 || children.length > 0) && (
25
+ <details class="top-page-menu">
26
+ <summary aria-label={label} title={label}>
27
+ <ChevronDown aria-hidden="true" />
28
+ </summary>
29
+ <div class="top-page-panel">
30
+ {sections.length > 0 && (
31
+ <nav aria-label={`${projectConfig.locale.labels.pageSections}: ${node.node.title}`}>
32
+ <ul class="top-page-sections">
33
+ {sections.map((section) => (
34
+ <li><a href={`${pageHref}#${section.id}`}>{section.title}</a></li>
35
+ ))}
36
+ </ul>
37
+ </nav>
38
+ )}
39
+ {children.length > 0 && (
40
+ <nav class="top-page-children" aria-label={`${projectConfig.locale.labels.navigationChildren}: ${node.node.title}`}>
41
+ {sections.length > 0 && <p>{projectConfig.locale.labels.navigationChildren}</p>}
42
+ <NavigationPageTree nodes={children} currentPage={currentPage} variant="submenu" />
43
+ </nav>
44
+ )}
45
+ </div>
46
+ </details>
47
+ )}
@@ -62,15 +62,22 @@
62
62
  branch.open,
63
63
  ])),
64
64
  });
65
- const applyDisclosureState = (state: SharedNavigationState) => {
65
+ const applyDisclosureState = (
66
+ state: SharedNavigationState,
67
+ openCurrentPageOnArrival = false,
68
+ ) => {
66
69
  branches.forEach((branch) => {
67
70
  const pagePath = branch.dataset.pagePath ?? '';
68
- branch.open = branch.dataset.currentBranch === 'true'
71
+ branch.open = (openCurrentPageOnArrival && branch.dataset.currentBranch === 'true')
69
72
  || state.openPaths.includes(pagePath);
70
73
  });
71
74
  sectionBranches.forEach((branch) => {
72
75
  const pagePath = branch.dataset.pagePath ?? '';
73
76
  const storedOpen = state.sectionOpenByPath[pagePath];
77
+ if (openCurrentPageOnArrival && branch.dataset.currentPage === 'true') {
78
+ branch.open = true;
79
+ return;
80
+ }
74
81
  branch.open = typeof storedOpen === 'boolean'
75
82
  ? storedOpen
76
83
  : branch.dataset.currentPage === 'true';
@@ -96,7 +103,7 @@
96
103
  const storedSharedState = sessionStorage.getItem(sharedStateKey);
97
104
  applyDisclosureState(storedSharedState === null
98
105
  ? storedState
99
- : parseState(storedSharedState));
106
+ : parseState(storedSharedState), true);
100
107
  const initialSharedState = mergeSharedState(getDisclosureState());
101
108
  lastSharedState = JSON.stringify(initialSharedState);
102
109
  sessionStorage.setItem(sharedStateKey, lastSharedState);