@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
@@ -0,0 +1,33 @@
1
+ import { defineHastPlugin } from 'satteri';
2
+
3
+ export const nornaTableRenderPlugin = defineHastPlugin({
4
+ name: 'norna-table-render',
5
+ element: {
6
+ filter: ['table'],
7
+ visit(node, context) {
8
+ context.replaceNode(node, {
9
+ type: 'element',
10
+ tagName: 'div',
11
+ properties: {
12
+ className: ['norna-table-frame', 'content-block-note-lane-boundary'],
13
+ 'data-table-frame': '',
14
+ },
15
+ children: [{
16
+ type: 'element',
17
+ tagName: 'div',
18
+ properties: {
19
+ className: ['norna-table-scroll'],
20
+ 'data-table-scroll': '',
21
+ tabIndex: 0,
22
+ },
23
+ children: [{
24
+ type: 'element',
25
+ tagName: node.tagName,
26
+ properties: { ...(node.properties ?? {}) },
27
+ children: [...(node.children ?? [])],
28
+ }],
29
+ }],
30
+ });
31
+ },
32
+ },
33
+ });
@@ -32,6 +32,7 @@ const cardImageSizes = [
32
32
  const displayLink = (link: string) => withBasePath(projectConfig.site.basePath, link);
33
33
  const cardListClasses = [
34
34
  'card-list',
35
+ 'content-block-note-lane-boundary',
35
36
  `card-list-layout-${layout}`,
36
37
  `card-list-flow-${flow}`,
37
38
  `card-list-size-${size}`,
@@ -77,7 +77,8 @@ const labels = {
77
77
 
78
78
  const button = template.content.firstElementChild?.cloneNode(true);
79
79
  if (!(button instanceof HTMLButtonElement)) return;
80
- wrapper.append(button);
80
+ const title = codeExample?.querySelector(':scope > .norna-code-title');
81
+ (title ?? wrapper).append(button);
81
82
  button.addEventListener('click', async () => {
82
83
  if (button.dataset.copyBusy === 'true') return;
83
84
  button.dataset.copyBusy = 'true';
@@ -45,7 +45,10 @@ const carouselStyle = carouselAspectRatio
45
45
  : undefined;
46
46
  ---
47
47
 
48
- <div class="managed-images" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
48
+ <div
49
+ class="managed-images content-block-note-lane-boundary"
50
+ aria-label={`${projectConfig.locale.labels.images}: ${title}`}
51
+ >
49
52
  <section
50
53
  class="managed-image-item image-carousel"
51
54
  data-carousel
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
- import { getImageAttributes } from '../lib/generatedImages';
3
+ import { Maximize2 } from '@lucide/astro';
4
+ import { getImageAttributes, getImageInspectionAttributes } from '../lib/generatedImages';
4
5
  import {
5
6
  getCaptionId,
6
7
  getManagedImageSizes,
@@ -23,21 +24,45 @@ const { title, images, maxAvailableWidthPercent, priorityFirstImage = false } =
23
24
  const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
24
25
  ---
25
26
 
26
- <div class="image-stack" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
27
+ <div
28
+ class="image-stack content-block-note-lane-boundary"
29
+ aria-label={`${projectConfig.locale.labels.images}: ${title}`}
30
+ >
27
31
  {images.map((image, itemIndex) => {
28
32
  const isPriorityItem = priorityFirstImage && itemIndex === 0;
29
33
  const captionId = image.caption ? getCaptionId(image, itemIndex) : undefined;
34
+ const inspection = getImageInspectionAttributes(image.src ?? image.image);
35
+ const inspectionDescription = image.alt || image.caption || `${title} ${itemIndex + 1}`;
36
+ const inspectionLabel = projectConfig.locale.labels.inspectImage.replace('{description}', inspectionDescription);
37
+ const inspectionStyle = inspection.width && inspection.height
38
+ ? `--managed-image-intrinsic-width: ${inspection.width}px; --managed-image-aspect-ratio: ${inspection.width / inspection.height}`
39
+ : undefined;
30
40
 
31
41
  return (
32
- <figure class="managed-image-item">
42
+ <figure class="managed-image-item" data-image-stack-figure>
33
43
  <div class="managed-image-frame">
34
- <img
35
- {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
36
- class:list={['managed-image', getImageClass(image)]}
37
- alt={image.alt ?? ''}
44
+ <a
45
+ class="managed-image-inspection-link"
46
+ href={inspection.href}
47
+ aria-label={inspectionLabel}
38
48
  aria-describedby={captionId}
39
- {...getImageLoadingProps(isPriorityItem)}
40
- />
49
+ data-image-inspection-trigger
50
+ data-image-intrinsic-width={inspection.width}
51
+ data-image-intrinsic-height={inspection.height}
52
+ data-image-inspection-scalable={inspection.scalable ? 'true' : undefined}
53
+ style={inspectionStyle}
54
+ >
55
+ <img
56
+ {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
57
+ class:list={['managed-image', getImageClass(image)]}
58
+ alt={image.alt ?? ''}
59
+ aria-describedby={captionId}
60
+ {...getImageLoadingProps(isPriorityItem)}
61
+ />
62
+ <span class="managed-image-inspection-affordance" aria-hidden="true">
63
+ <Maximize2 size={18} stroke-width={2} />
64
+ </span>
65
+ </a>
41
66
  </div>
42
67
  {image.caption && (
43
68
  <figcaption class="image-meta" id={captionId}>
@@ -0,0 +1,331 @@
1
+ ---
2
+ import { Maximize2, Minimize2, X } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+
5
+ const labels = projectConfig.locale.labels;
6
+ ---
7
+
8
+ <dialog class="image-inspector" data-image-inspector aria-label={labels.imageInspection}>
9
+ <div class="image-inspector-layout">
10
+ <div class="image-inspector-controls">
11
+ <button
12
+ type="button"
13
+ data-image-inspector-size
14
+ hidden
15
+ aria-label={labels.imageInspectionActualSize}
16
+ title={labels.imageInspectionActualSize}
17
+ data-fit-label={labels.imageInspectionFit}
18
+ data-actual-label={labels.imageInspectionActualSize}
19
+ >
20
+ <Maximize2 aria-hidden="true" data-image-inspector-size-icon="actual" size={20} stroke-width={2} />
21
+ <Minimize2 aria-hidden="true" data-image-inspector-size-icon="fit" hidden size={20} stroke-width={2} />
22
+ </button>
23
+ <button
24
+ type="button"
25
+ data-image-inspector-close
26
+ aria-label={labels.imageInspectionClose}
27
+ title={labels.imageInspectionClose}
28
+ >
29
+ <X aria-hidden="true" size={20} stroke-width={2} />
30
+ </button>
31
+ </div>
32
+ <div class="image-inspector-media" data-image-inspector-media data-image-inspector-mode="fit">
33
+ <img data-image-inspector-image alt="" />
34
+ </div>
35
+ <p class="image-inspector-caption" id="image-inspector-caption" data-image-inspector-caption hidden></p>
36
+ </div>
37
+ </dialog>
38
+
39
+ <script>
40
+ const dialog = document.querySelector<HTMLDialogElement>('[data-image-inspector]');
41
+ const triggers = Array.from(document.querySelectorAll<HTMLAnchorElement>('[data-image-inspection-trigger]'));
42
+ const figures = Array.from(document.querySelectorAll<HTMLElement>('[data-image-stack-figure]'));
43
+ const enlargementThreshold = 1.2;
44
+ const persistentCaptionViewportWidth = 1101;
45
+ const persistentCaptionMinimumWidth = 160;
46
+ const persistentCaptionHeightRatio = 0.9;
47
+ let captionScheduledFrame: number | null = null;
48
+
49
+ const isVisible = (element: Element) => {
50
+ const style = getComputedStyle(element);
51
+ const bounds = element.getBoundingClientRect();
52
+ return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
53
+ };
54
+
55
+ const rectanglesOverlap = (first: DOMRect, second: DOMRect) => (
56
+ first.left < second.right
57
+ && first.right > second.left
58
+ && first.top < second.bottom
59
+ && first.bottom > second.top
60
+ );
61
+
62
+ const resolveCssLength = (value: string, fallback: number) => {
63
+ const probe = document.createElement('span');
64
+ probe.style.cssText = [
65
+ 'position:fixed',
66
+ 'visibility:hidden',
67
+ 'pointer-events:none',
68
+ 'box-sizing:content-box',
69
+ 'height:0',
70
+ 'padding:0',
71
+ 'border:0',
72
+ `width:${value.trim() || `${fallback}px`}`,
73
+ ].join(';');
74
+ document.body.append(probe);
75
+ const width = probe.getBoundingClientRect().width;
76
+ probe.remove();
77
+ return Number.isFinite(width) && width > 0 ? width : fallback;
78
+ };
79
+
80
+ const clearPersistentCaption = (figure: HTMLElement) => {
81
+ delete figure.dataset.imageCaptionPlacement;
82
+ figure.style.removeProperty('--image-persistent-caption-inline-start');
83
+ figure.style.removeProperty('--image-persistent-caption-width');
84
+ figure.style.removeProperty('--image-persistent-caption-reserve');
85
+ figure.style.removeProperty('--image-persistent-caption-block-start');
86
+ figure.style.removeProperty('--image-persistent-caption-block-end');
87
+ };
88
+
89
+ const updatePersistentCaption = (figure: HTMLElement) => {
90
+ clearPersistentCaption(figure);
91
+ if (window.innerWidth < persistentCaptionViewportWidth) return;
92
+
93
+ const frame = figure.querySelector<HTMLElement>('.managed-image-frame');
94
+ const caption = figure.querySelector<HTMLElement>('.image-meta');
95
+ const image = frame?.querySelector<HTMLImageElement>('img');
96
+ const layout = figure.closest<HTMLElement>('.site-page-layout-tree')
97
+ ?? figure.closest<HTMLElement>('.site-content');
98
+ if (!frame || !caption || !image || !layout || !isVisible(frame) || !isVisible(caption)) return;
99
+
100
+ const headerOffset = Number.parseFloat(
101
+ getComputedStyle(document.documentElement).getPropertyValue('--site-top-anchor-offset'),
102
+ ) || 0;
103
+ const usableViewportHeight = Math.max(0, window.innerHeight - headerOffset);
104
+ const imageBounds = image.getBoundingClientRect();
105
+ if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
106
+
107
+ const visibleContentsRail = Array.from(
108
+ layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
109
+ ).some(isVisible);
110
+ const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
111
+ && document.documentElement.dataset.focusReading !== 'on';
112
+ if (visibleContentsRail || reservesContentsLane) return;
113
+
114
+ const frameBounds = frame.getBoundingClientRect();
115
+ const figureBounds = figure.getBoundingClientRect();
116
+ const captionBounds = caption.getBoundingClientRect();
117
+ const layoutBounds = layout.getBoundingClientRect();
118
+ const rootStyle = getComputedStyle(document.documentElement);
119
+ const figureStyle = getComputedStyle(figure);
120
+ const gap = resolveCssLength(figureStyle.getPropertyValue('--image-caption-inline-gap'), 12);
121
+ const preferredWidth = resolveCssLength(rootStyle.getPropertyValue('--section-note-width'), 192);
122
+ const isRightToLeft = getComputedStyle(figure).direction === 'rtl';
123
+ const availableWidth = isRightToLeft
124
+ ? imageBounds.left - layoutBounds.left - gap
125
+ : layoutBounds.right - imageBounds.right - gap;
126
+ const captionWidth = Math.min(preferredWidth, availableWidth);
127
+ if (captionWidth < persistentCaptionMinimumWidth || captionBounds.height > imageBounds.height) return;
128
+
129
+ const proposedBounds = new DOMRect(
130
+ isRightToLeft ? imageBounds.left - gap - captionWidth : imageBounds.right + gap,
131
+ imageBounds.top,
132
+ captionWidth,
133
+ imageBounds.height,
134
+ );
135
+ const laneClaims = Array.from(figure.closest('.site-section')?.querySelectorAll<HTMLElement>([
136
+ '.section-note',
137
+ '[data-table-layout="end"]',
138
+ '[data-table-layout="canvas"]',
139
+ ].join(', ')) ?? []).filter((element) => element !== figure && isVisible(element));
140
+ if (laneClaims.some((element) => rectanglesOverlap(proposedBounds, element.getBoundingClientRect()))) return;
141
+
142
+ const reserve = Math.max(0, captionBounds.bottom - frameBounds.bottom);
143
+ const inlineStart = isRightToLeft
144
+ ? figureBounds.right - imageBounds.left + gap
145
+ : imageBounds.right - figureBounds.left + gap;
146
+ const blockStart = imageBounds.top - figureBounds.top;
147
+ const blockEnd = figureBounds.bottom - imageBounds.bottom;
148
+ figure.style.setProperty('--image-persistent-caption-inline-start', `${inlineStart}px`);
149
+ figure.style.setProperty('--image-persistent-caption-width', `${captionWidth}px`);
150
+ figure.style.setProperty('--image-persistent-caption-reserve', `${reserve}px`);
151
+ figure.style.setProperty('--image-persistent-caption-block-start', `${blockStart}px`);
152
+ figure.style.setProperty('--image-persistent-caption-block-end', `${blockEnd}px`);
153
+ figure.dataset.imageCaptionPlacement = 'persistent';
154
+ };
155
+
156
+ const updatePersistentCaptions = () => {
157
+ captionScheduledFrame = null;
158
+ figures.forEach(updatePersistentCaption);
159
+ };
160
+
161
+ const schedulePersistentCaptionUpdate = () => {
162
+ if (captionScheduledFrame !== null) return;
163
+ captionScheduledFrame = requestAnimationFrame(updatePersistentCaptions);
164
+ };
165
+
166
+ if (figures.length > 0) {
167
+ figures.forEach((figure) => {
168
+ const image = figure.querySelector('img');
169
+ if (image && !image.complete) image.addEventListener('load', schedulePersistentCaptionUpdate, { once: true });
170
+ });
171
+ if ('ResizeObserver' in window) {
172
+ const observer = new ResizeObserver(schedulePersistentCaptionUpdate);
173
+ figures.forEach((figure) => {
174
+ const frame = figure.querySelector('.managed-image-frame');
175
+ if (frame) observer.observe(frame);
176
+ });
177
+ }
178
+ const preferenceObserver = new MutationObserver(schedulePersistentCaptionUpdate);
179
+ preferenceObserver.observe(document.documentElement, {
180
+ attributes: true,
181
+ attributeFilter: ['data-focus-reading', 'data-reading-width'],
182
+ });
183
+ window.addEventListener('resize', schedulePersistentCaptionUpdate, { passive: true });
184
+ document.fonts?.ready.then(schedulePersistentCaptionUpdate).catch(() => {});
185
+ updatePersistentCaptions();
186
+ }
187
+
188
+ if (dialog && typeof dialog.showModal === 'function' && triggers.length > 0) {
189
+ const inspectedImage = dialog.querySelector<HTMLImageElement>('[data-image-inspector-image]');
190
+ const media = dialog.querySelector<HTMLElement>('[data-image-inspector-media]');
191
+ const caption = dialog.querySelector<HTMLElement>('[data-image-inspector-caption]');
192
+ const sizeButton = dialog.querySelector<HTMLButtonElement>('[data-image-inspector-size]');
193
+ const closeButton = dialog.querySelector<HTMLButtonElement>('[data-image-inspector-close]');
194
+ let activeTrigger: HTMLAnchorElement | null = null;
195
+ let scheduledFrame: number | null = null;
196
+ let sizeControlScheduledFrame: number | null = null;
197
+
198
+ const setInspectorMode = (mode: 'fit' | 'actual') => {
199
+ if (!media || !sizeButton) return;
200
+ media.dataset.imageInspectorMode = mode;
201
+ const nextLabel = mode === 'fit'
202
+ ? sizeButton.dataset.actualLabel
203
+ : sizeButton.dataset.fitLabel;
204
+ if (nextLabel) {
205
+ sizeButton.setAttribute('aria-label', nextLabel);
206
+ sizeButton.title = nextLabel;
207
+ }
208
+ sizeButton.querySelectorAll<SVGElement>('[data-image-inspector-size-icon]').forEach((icon) => {
209
+ icon.hidden = icon.dataset.imageInspectorSizeIcon !== (mode === 'fit' ? 'actual' : 'fit');
210
+ });
211
+ };
212
+
213
+ const canInspect = (trigger: HTMLAnchorElement) => {
214
+ const image = trigger.querySelector('img');
215
+ const width = Number(trigger.dataset.imageIntrinsicWidth);
216
+ const height = Number(trigger.dataset.imageIntrinsicHeight);
217
+ if (!image || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
218
+ return false;
219
+ }
220
+
221
+ const bounds = image.getBoundingClientRect();
222
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
223
+ const captionReserve = trigger.getAttribute('aria-describedby') ? 176 : 112;
224
+ const availableWidth = Math.max(0, window.innerWidth - 48);
225
+ const availableHeight = Math.max(0, window.innerHeight - captionReserve);
226
+ const fitScale = Math.min(1, availableWidth / width, availableHeight / height);
227
+ const renderedScale = Math.min(bounds.width / width, bounds.height / height);
228
+ const fitGain = fitScale / renderedScale;
229
+ const actualSizeGain = 1 / renderedScale;
230
+
231
+ return Math.max(fitGain, actualSizeGain) >= enlargementThreshold;
232
+ };
233
+
234
+ const updateSizeControlAvailability = () => {
235
+ sizeControlScheduledFrame = null;
236
+ if (!activeTrigger || !inspectedImage || !media || !sizeButton) return;
237
+
238
+ const isScalable = activeTrigger.dataset.imageInspectionScalable === 'true';
239
+ const intrinsicWidth = inspectedImage.naturalWidth;
240
+ const intrinsicHeight = inspectedImage.naturalHeight;
241
+ const fitScale = intrinsicWidth > 0 && intrinsicHeight > 0
242
+ ? Math.min(1, media.clientWidth / intrinsicWidth, media.clientHeight / intrinsicHeight)
243
+ : 1;
244
+ const actualSizeGain = fitScale > 0 ? 1 / fitScale : 1;
245
+ const isUseful = !isScalable && actualSizeGain >= enlargementThreshold;
246
+
247
+ if (!isUseful && media.dataset.imageInspectorMode === 'actual') setInspectorMode('fit');
248
+ if (!isUseful && document.activeElement === sizeButton) closeButton?.focus();
249
+ sizeButton.hidden = !isUseful;
250
+ sizeButton.disabled = !isUseful;
251
+ };
252
+
253
+ const scheduleSizeControlUpdate = () => {
254
+ if (sizeControlScheduledFrame !== null) return;
255
+ sizeControlScheduledFrame = requestAnimationFrame(updateSizeControlAvailability);
256
+ };
257
+
258
+ const updateAvailability = () => {
259
+ scheduledFrame = null;
260
+ triggers.forEach((trigger) => {
261
+ trigger.dataset.imageInspectionAvailable = canInspect(trigger) ? 'true' : 'false';
262
+ });
263
+ };
264
+
265
+ const scheduleAvailabilityUpdate = () => {
266
+ if (scheduledFrame !== null) return;
267
+ scheduledFrame = requestAnimationFrame(updateAvailability);
268
+ };
269
+
270
+ const openInspector = (trigger: HTMLAnchorElement) => {
271
+ if (!inspectedImage || !caption || !closeButton) return;
272
+ const sourceImage = trigger.querySelector('img');
273
+ const captionId = trigger.getAttribute('aria-describedby');
274
+ const sourceCaption = captionId ? document.getElementById(captionId) : null;
275
+ activeTrigger = trigger;
276
+ sizeButton?.setAttribute('disabled', '');
277
+ if (sizeButton) sizeButton.hidden = true;
278
+ inspectedImage.src = trigger.href;
279
+ inspectedImage.alt = sourceImage?.alt ?? '';
280
+ if (sourceCaption?.textContent?.trim()) {
281
+ caption.textContent = sourceCaption.textContent.trim();
282
+ caption.hidden = false;
283
+ inspectedImage.setAttribute('aria-describedby', caption.id);
284
+ } else {
285
+ caption.textContent = '';
286
+ caption.hidden = true;
287
+ inspectedImage.removeAttribute('aria-describedby');
288
+ }
289
+ setInspectorMode('fit');
290
+ document.documentElement.dataset.imageInspectorOpen = 'true';
291
+ dialog.showModal();
292
+ closeButton.focus();
293
+ if (inspectedImage.complete) scheduleSizeControlUpdate();
294
+ };
295
+
296
+ triggers.forEach((trigger) => {
297
+ const image = trigger.querySelector('img');
298
+ if (image && !image.complete) image.addEventListener('load', scheduleAvailabilityUpdate, { once: true });
299
+ trigger.addEventListener('click', (event) => {
300
+ if (trigger.dataset.imageInspectionAvailable !== 'true') return;
301
+ event.preventDefault();
302
+ openInspector(trigger);
303
+ });
304
+ });
305
+
306
+ sizeButton?.addEventListener('click', () => {
307
+ setInspectorMode(media?.dataset.imageInspectorMode === 'actual' ? 'fit' : 'actual');
308
+ });
309
+ closeButton?.addEventListener('click', () => dialog.close());
310
+ dialog.addEventListener('close', () => {
311
+ delete document.documentElement.dataset.imageInspectorOpen;
312
+ activeTrigger?.focus();
313
+ activeTrigger = null;
314
+ if (inspectedImage) inspectedImage.removeAttribute('src');
315
+ });
316
+
317
+ if ('ResizeObserver' in window) {
318
+ const observer = new ResizeObserver(scheduleAvailabilityUpdate);
319
+ triggers.forEach((trigger) => observer.observe(trigger));
320
+ if (media) new ResizeObserver(scheduleSizeControlUpdate).observe(media);
321
+ } else {
322
+ window.addEventListener('resize', () => {
323
+ scheduleAvailabilityUpdate();
324
+ scheduleSizeControlUpdate();
325
+ }, { passive: true });
326
+ }
327
+ inspectedImage?.addEventListener('load', scheduleSizeControlUpdate);
328
+ document.fonts?.ready.then(scheduleAvailabilityUpdate).catch(() => {});
329
+ updateAvailability();
330
+ }
331
+ </script>
@@ -6,14 +6,13 @@ import PageContentsLinks from './PageContentsLinks.astro';
6
6
  interface Props {
7
7
  headings: HeadingNavigation[];
8
8
  pageTitle: string;
9
- variant: 'inline' | 'rail';
10
9
  }
11
10
 
12
- const { headings, pageTitle, variant } = Astro.props;
11
+ const { headings, pageTitle } = Astro.props;
13
12
  const label = `${projectConfig.locale.labels.pageNavigation}: ${pageTitle}`;
14
13
  ---
15
14
 
16
- <aside class={`page-contents-navigation page-contents-navigation-${variant}`}>
15
+ <aside class="page-contents-navigation page-contents-navigation-rail">
17
16
  <nav aria-label={label}>
18
17
  <p class="page-contents-navigation-label">{projectConfig.locale.labels.pageNavigation}</p>
19
18
  <PageContentsLinks headings={headings} variant="desktop" />
@@ -7,6 +7,8 @@
7
7
  const submenuItems = Array.from(document.querySelectorAll<HTMLElement>('.site-nav-item-has-submenu'));
8
8
  const mobileDestinationLinks = Array.from(mobileMenu?.querySelectorAll<HTMLAnchorElement>('.mobile-nav-destination') ?? []);
9
9
  const mobileMenuSummary = mobileMenu?.querySelector<HTMLElement>(':scope > summary');
10
+ const mobileMenuPanel = mobileMenu?.querySelector<HTMLElement>('[data-compact-navigation-panel]');
11
+ const mobileMenuClose = mobileMenu?.querySelector<HTMLButtonElement>('[data-compact-navigation-close]');
10
12
  const sectionTrackingEnabled = document.documentElement.dataset.sectionTracking === 'enabled';
11
13
  const trackedHeadingIds = new Set(sectionLinks.flatMap((link) => {
12
14
  const linkUrl = new URL(link.href, window.location.href);
@@ -158,17 +160,54 @@
158
160
  });
159
161
 
160
162
  if (mobileMenu) {
163
+ const siteHeader = mobileMenu.closest<HTMLElement>('.site-top');
164
+ const navigationRow = mobileMenu.closest<HTMLElement>('.site-nav-row');
165
+ const navigationActions = mobileMenu.closest<HTMLElement>('.site-nav-actions');
166
+ const backgroundTargets = new Set<HTMLElement>([
167
+ ...Array.from(document.body.children)
168
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== siteHeader),
169
+ ...Array.from(siteHeader?.children ?? [])
170
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== navigationRow),
171
+ ...Array.from(navigationRow?.children ?? [])
172
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== navigationActions),
173
+ ...Array.from(navigationActions?.children ?? [])
174
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== mobileMenu),
175
+ ]);
176
+ const previousInertState = new Map<HTMLElement, boolean>();
177
+ const setBackgroundInert = (inert: boolean) => {
178
+ if (inert) {
179
+ backgroundTargets.forEach((element) => {
180
+ if (!previousInertState.has(element)) {
181
+ previousInertState.set(element, element.hasAttribute('inert'));
182
+ }
183
+ element.setAttribute('inert', '');
184
+ });
185
+ return;
186
+ }
187
+
188
+ previousInertState.forEach((wasInert, element) => {
189
+ if (!wasInert) element.removeAttribute('inert');
190
+ });
191
+ previousInertState.clear();
192
+ };
193
+ const closeMenu = (restoreFocus: boolean) => {
194
+ mobileMenu.open = false;
195
+ if (restoreFocus) mobileMenuSummary?.focus();
196
+ };
197
+
161
198
  mobileDestinationLinks.forEach((link) => {
162
199
  link.addEventListener('click', () => {
163
- mobileMenu.open = false;
200
+ closeMenu(false);
164
201
  });
165
202
  });
203
+ mobileMenuClose?.addEventListener('click', () => closeMenu(true));
166
204
 
167
205
  mobileMenu.addEventListener('toggle', () => {
168
206
  document.documentElement.classList.toggle('mobile-navigation-open', mobileMenu.open);
207
+ setBackgroundInert(mobileMenu.open);
169
208
  if (mobileMenu.open) {
170
209
  window.requestAnimationFrame(() => (
171
- mobileMenu.querySelector<HTMLElement>('.mobile-nav-view h2, .mobile-nav-view a')?.focus()
210
+ mobileMenuClose?.focus()
172
211
  ));
173
212
  }
174
213
  });
@@ -178,16 +217,15 @@
178
217
 
179
218
  if (event.key === 'Escape') {
180
219
  event.preventDefault();
181
- mobileMenu.open = false;
182
- mobileMenuSummary?.focus();
220
+ closeMenu(true);
183
221
  return;
184
222
  }
185
223
 
186
224
  if (event.key !== 'Tab') return;
187
225
 
188
- const focusableElements = Array.from(mobileMenu.querySelectorAll<HTMLElement>(
189
- 'summary, a[href]:not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])',
190
- )).filter((element) => element.getClientRects().length > 0);
226
+ const focusableElements = Array.from(mobileMenuPanel?.querySelectorAll<HTMLElement>(
227
+ 'a[href]:not([hidden]), button:not([disabled]):not([hidden]), input:not([disabled]):not([hidden]), summary, [tabindex]:not([tabindex="-1"]):not([hidden])',
228
+ ) ?? []).filter((element) => element.getClientRects().length > 0);
191
229
  const firstFocusable = focusableElements[0];
192
230
  const lastFocusable = focusableElements.at(-1);
193
231
  if (!firstFocusable || !lastFocusable) return;