@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.
- package/README.md +3 -2
- package/astro.config.mjs +26 -12
- package/bin/norna-cli.mjs +12 -0
- package/package.json +14 -5
- package/schemas/card-list.schema.json +121 -0
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +306 -32
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/image-carousel.schema.json +43 -0
- package/schemas/image-stack.schema.json +43 -0
- package/schemas/manifest.json +6 -1
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -262
- package/scripts/dev-local.mjs +24 -2
- package/scripts/generate-schemas.mjs +15 -3
- package/scripts/lib/category-destinations.mjs +48 -0
- package/scripts/lib/code-fence-metadata.mjs +14 -37
- package/scripts/lib/content-tabs.mjs +155 -0
- package/scripts/lib/details-headings.mjs +58 -0
- package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
- package/scripts/lib/editor-language-service.mjs +238 -116
- package/scripts/lib/locale-registry.mjs +597 -0
- package/scripts/lib/locales/az-Latn.mjs +78 -0
- package/scripts/lib/locales/be.mjs +78 -0
- package/scripts/lib/locales/bg.mjs +78 -0
- package/scripts/lib/locales/bs.mjs +78 -0
- package/scripts/lib/locales/ca.mjs +78 -0
- package/scripts/lib/locales/cnr.mjs +78 -0
- package/scripts/lib/locales/cs.mjs +78 -0
- package/scripts/lib/locales/da.mjs +78 -0
- package/scripts/lib/locales/de.mjs +78 -0
- package/scripts/lib/locales/el.mjs +78 -0
- package/scripts/lib/locales/en.mjs +78 -0
- package/scripts/lib/locales/es.mjs +78 -0
- package/scripts/lib/locales/et.mjs +78 -0
- package/scripts/lib/locales/fi.mjs +78 -0
- package/scripts/lib/locales/fil.mjs +78 -0
- package/scripts/lib/locales/fr.mjs +78 -0
- package/scripts/lib/locales/ga.mjs +78 -0
- package/scripts/lib/locales/ha.mjs +78 -0
- package/scripts/lib/locales/hr.mjs +78 -0
- package/scripts/lib/locales/hu.mjs +78 -0
- package/scripts/lib/locales/id.mjs +78 -0
- package/scripts/lib/locales/is.mjs +78 -0
- package/scripts/lib/locales/it.mjs +78 -0
- package/scripts/lib/locales/jv.mjs +78 -0
- package/scripts/lib/locales/lb.mjs +78 -0
- package/scripts/lib/locales/lt.mjs +78 -0
- package/scripts/lib/locales/lv.mjs +78 -0
- package/scripts/lib/locales/mk.mjs +78 -0
- package/scripts/lib/locales/mt.mjs +78 -0
- package/scripts/lib/locales/nb.mjs +78 -0
- package/scripts/lib/locales/nl.mjs +78 -0
- package/scripts/lib/locales/nn.mjs +78 -0
- package/scripts/lib/locales/pcm.mjs +78 -0
- package/scripts/lib/locales/pl.mjs +78 -0
- package/scripts/lib/locales/pt.mjs +78 -0
- package/scripts/lib/locales/ro.mjs +78 -0
- package/scripts/lib/locales/ru.mjs +78 -0
- package/scripts/lib/locales/sk.mjs +78 -0
- package/scripts/lib/locales/sl.mjs +78 -0
- package/scripts/lib/locales/sq.mjs +78 -0
- package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
- package/scripts/lib/locales/sr-Latn.mjs +78 -0
- package/scripts/lib/locales/sv.mjs +78 -0
- package/scripts/lib/locales/sw.mjs +78 -0
- package/scripts/lib/locales/tr.mjs +78 -0
- package/scripts/lib/locales/uk.mjs +78 -0
- package/scripts/lib/locales/vi.mjs +78 -0
- package/scripts/lib/markdown-links.mjs +4 -25
- package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
- package/scripts/lib/markdown-notes.mjs +165 -0
- package/scripts/lib/norna-markdown-blocks.mjs +32 -447
- package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
- package/scripts/lib/page-aliases.mjs +12 -1
- package/scripts/lib/page-markdown.mjs +63 -16
- package/scripts/lib/page-move-plan.mjs +22 -5
- package/scripts/lib/pagefind-translations.mjs +620 -0
- package/scripts/lib/presentation-contract.mjs +6 -1
- package/scripts/lib/presentation.mjs +29 -30
- package/scripts/lib/project-config.mjs +2 -173
- package/scripts/lib/schema-definitions.mjs +3 -10
- package/scripts/lib/schema-editor-metadata.mjs +22 -43
- package/scripts/lib/schema-value-definitions.mjs +4 -8
- package/scripts/lib/semantic-callouts.mjs +4 -2
- package/scripts/lib/site-content.mjs +4 -2
- package/scripts/lib/site-link-graph.mjs +16 -6
- package/scripts/lib/site-navigation-tree.mjs +0 -11
- package/scripts/lib/sitemap.mjs +5 -3
- package/scripts/lib/structured-blocks.mjs +108 -0
- package/scripts/lib/table-render-plugin.mjs +46 -3
- package/scripts/lib/table-row-headers.mjs +218 -0
- package/scripts/lib/theme-presets.mjs +3 -10
- package/scripts/lib/yaml-config.mjs +2 -6
- package/scripts/migrate-check.mjs +19 -0
- package/scripts/sync-content-sections.mjs +39 -10
- package/scripts/sync-site-public.mjs +7 -1
- package/src/components/CodeBlockCopyScript.astro +47 -5
- package/src/components/ContentBlocks.astro +39 -0
- package/src/components/ContentTabsScript.astro +60 -0
- package/src/components/DisplaySettings.astro +17 -19
- package/src/components/NavigationPageTree.astro +1 -1
- package/src/components/NoteNavigationScript.astro +4 -0
- package/src/components/PageAliasRedirect.astro +5 -4
- package/src/components/SearchNavigationScript.astro +4 -0
- package/src/components/SearchPage.astro +43 -2
- package/src/components/SectionNavigationScript.astro +43 -11
- package/src/components/SiteBreadcrumbs.astro +1 -1
- package/src/components/SiteNavigation.astro +29 -20
- package/src/components/SitePage.astro +40 -15
- package/src/components/SiteSection.astro +5 -81
- package/src/components/SiteTreeNavigation.astro +2 -2
- package/src/components/TableOverflowScript.astro +386 -58
- package/src/components/TopPageMenu.astro +47 -0
- package/src/components/TreeNavigationScript.astro +10 -3
- package/src/layouts/BaseLayout.astro +8 -12
- package/src/lib/navigationFollowing.ts +142 -0
- package/src/lib/noteNavigation.ts +50 -0
- package/src/lib/searchNavigation.ts +107 -0
- package/src/lib/sectionContent.ts +20 -90
- package/src/lib/siteNavigation.ts +0 -5
- package/src/lib/sitePages.ts +13 -2
- package/src/lib/table-sort-tooltip.ts +109 -0
- package/src/pages/[...slug].astro +16 -6
- package/src/styles/content.css +394 -132
- package/src/styles/foundations.css +9 -0
- package/src/styles/media.css +5 -0
- package/src/styles/navigation.css +63 -74
- package/src/styles/page-layout.css +7 -0
- package/src/styles/responsive.css +2 -7
|
@@ -1,38 +1,48 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
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-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
54
|
-
let
|
|
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
|
-
|
|
97
|
+
stickyContext.prepend(stickyHeading);
|
|
76
98
|
};
|
|
77
99
|
|
|
78
100
|
const createTableNavigation = () => {
|
|
79
101
|
if (!navigationTemplate) return;
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
+
headingCells.forEach((headingCell, columnIndex) => {
|
|
132
385
|
const cellBounds = headingCell.getBoundingClientRect();
|
|
133
386
|
const computedStyle = getComputedStyle(headingCell);
|
|
134
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
194
|
-
if (
|
|
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 = (
|
|
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);
|