scavold 0.2.0-rc.9 → 0.2.0

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/FRONTMATTER.md CHANGED
@@ -274,6 +274,36 @@ in the hierarchy tree — they are only excluded from rendered navigation.
274
274
 
275
275
  ---
276
276
 
277
+ ## `menus`
278
+
279
+ **Type:** `string | string[]`
280
+
281
+ Names the menus this page appears in: menus the site declares under
282
+ [`menus`](./docs/reference/config.md#menus) in `.cratly.config.yaml`, plus `auto` for the menus following the
283
+ page tree. Pages then no longer need a folder of their own just to be collected into one
284
+ menu.
285
+
286
+ | Value | Effect |
287
+ |---|---|
288
+ | absent / empty | Same as `auto` (default) |
289
+ | `auto` | In the menus following the page tree, where the page sits among the folders |
290
+ | `footer` or `[ footer ]` | In the named menu only; the page tree's menus leave the page out |
291
+ | `[ auto, footer ]` | Both |
292
+
293
+ Choosing a named menu replaces `auto` rather than adding to it. [`hide: menu`](#hide)
294
+ rules over any choice. A name the site does not declare is warned about at build time.
295
+
296
+ Where a named menu shows up is decided by the theme, rendering it with
297
+ [`<ScavoldMenu menu="…">`](./COMPONENTS.md#scavoldmenu).
298
+
299
+ ```yaml
300
+ ---
301
+ menus: [ auto, footer ]
302
+ ---
303
+ ```
304
+
305
+ ---
306
+
277
307
  ## `aliases`
278
308
 
279
309
  **Type:** `string | string[]`
@@ -0,0 +1,162 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import { useAutoplay } from "../composables/useAutoplay.js";
4
+ import { useCover, coverProps } from "../composables/useCover.js";
5
+ import { useScavoldI18n } from "../composables/useI18n.js";
6
+ import ScavoldImage from "./ScavoldImage.vue";
7
+
8
+ const props = defineProps( coverProps );
9
+
10
+ const { src, isVideo, srcset, webpSrcset, sizes, poster, focus, label, tone } = useCover( props );
11
+
12
+ const { t } = useScavoldI18n();
13
+ const playLabel = t( "cover.play" );
14
+ const pauseLabel = t( "cover.pause" );
15
+
16
+ const video = ref( null );
17
+ const playing = ref( false );
18
+
19
+ const style = computed( () => ( focus.value ? { "--scavold-cover-focus": focus.value } : undefined ) );
20
+
21
+ // Plays while on screen, not for visitors asking for reduced motion — see useAutoplay.
22
+ useAutoplay( video, () => isVideo.value );
23
+
24
+ /**
25
+ * Starts or pauses the video. Moving content lasting longer than five seconds must
26
+ * be pausable (WCAG 2.2.2), which is why a cover video always carries this button.
27
+ */
28
+ function toggle() {
29
+ const element = video.value;
30
+
31
+ if ( !element ) {
32
+ return;
33
+ }
34
+
35
+ if ( element.paused ) {
36
+ element.play()?.catch( () => {
37
+ // refused by the browser; the button keeps offering to start it
38
+ } );
39
+ } else {
40
+ element.pause();
41
+ }
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <div
47
+ v-if="src"
48
+ class="scavold-cover"
49
+ :class="[ { 'scavold-cover--video': isVideo }, tone && `scavold-cover--${tone}` ]"
50
+ :style="style"
51
+ >
52
+ <video
53
+ v-if="isVideo"
54
+ ref="video"
55
+ class="scavold-cover__media"
56
+ :src="src"
57
+ :poster="poster || undefined"
58
+ :aria-label="label || undefined"
59
+ :aria-hidden="label ? undefined : 'true'"
60
+ preload="metadata"
61
+ muted
62
+ loop
63
+ playsinline
64
+ @play="playing = true"
65
+ @pause="playing = false"
66
+ />
67
+ <ScavoldImage
68
+ v-else
69
+ class="scavold-cover__media"
70
+ :src="src"
71
+ :srcset="srcset"
72
+ :webp-srcset="webpSrcset"
73
+ :sizes="sizes"
74
+ :alt="label"
75
+ loading="eager"
76
+ fetchpriority="high"
77
+ />
78
+
79
+ <div class="scavold-cover__overlay">
80
+ <slot />
81
+ </div>
82
+
83
+ <button
84
+ v-if="isVideo"
85
+ type="button"
86
+ class="scavold-cover__toggle"
87
+ :aria-label="playing ? pauseLabel : playLabel"
88
+ @click="toggle"
89
+ >
90
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
91
+ <path v-if="playing" d="M4 2h3v12H4zM9 2h3v12H9z" fill="currentColor" />
92
+ <path v-else d="M4 2l10 6-10 6z" fill="currentColor" />
93
+ </svg>
94
+ </button>
95
+ </div>
96
+ </template>
97
+
98
+ <style scoped>
99
+ /*
100
+ * Functional layout: the cover leaves the column it is written in and spans the
101
+ * window, at least as high as the window. Media and content share one grid cell,
102
+ * so content taller than the window grows the cover instead of being cut off.
103
+ *
104
+ * The breakout assumes a column centred in the window. Themes adjust the height
105
+ * through --scavold-cover-height, e.g. to leave room for a fixed header.
106
+ */
107
+ .scavold-cover {
108
+ position: relative;
109
+ display: grid;
110
+ overflow: hidden;
111
+ width: 100vw;
112
+ margin-inline: calc(50% - 50vw);
113
+ min-height: 100vh;
114
+ min-height: var(--scavold-cover-height, 100svh);
115
+ }
116
+
117
+ .scavold-cover__media,
118
+ .scavold-cover__overlay {
119
+ grid-area: 1 / 1;
120
+ min-width: 0;
121
+ min-height: 0;
122
+ }
123
+
124
+ .scavold-cover__media,
125
+ .scavold-cover__media :deep(img) {
126
+ display: block;
127
+ width: 100%;
128
+ height: 100%;
129
+ margin: 0;
130
+ border-radius: 0;
131
+ object-fit: cover;
132
+ object-position: var(--scavold-cover-focus, 50% 50%);
133
+ }
134
+
135
+ .scavold-cover__overlay {
136
+ position: relative;
137
+ z-index: 1;
138
+ }
139
+
140
+ /* A sensible default only — themes restyle it through the class. */
141
+ .scavold-cover__toggle {
142
+ position: absolute;
143
+ z-index: 2;
144
+ inset-block-end: 1rem;
145
+ inset-inline-end: 1rem;
146
+ display: grid;
147
+ place-items: center;
148
+ width: 2.75rem;
149
+ height: 2.75rem;
150
+ padding: 0;
151
+ border: 0;
152
+ border-radius: 50%;
153
+ color: #fff;
154
+ background: rgb(0 0 0 / 0.55);
155
+ cursor: pointer;
156
+ }
157
+
158
+ .scavold-cover__toggle:focus-visible {
159
+ outline: 2px solid #fff;
160
+ outline-offset: 2px;
161
+ }
162
+ </style>
@@ -5,6 +5,9 @@ defineProps( {
5
5
  webpSrcset: { type: String, default: "" },
6
6
  sizes: { type: String, default: "100vw" },
7
7
  alt: { type: String, default: "" },
8
+ // An image above the fold — a cover — is wanted at once, not when it scrolls into view.
9
+ loading: { type: String, default: "lazy" },
10
+ fetchpriority: { type: String, default: undefined },
8
11
  } );
9
12
  </script>
10
13
 
@@ -26,7 +29,8 @@ defineProps( {
26
29
  :alt="alt"
27
30
  :role="alt ? undefined : 'presentation'"
28
31
  :sizes="sizes"
29
- loading="lazy"
32
+ :loading="loading"
33
+ :fetchpriority="fetchpriority"
30
34
  decoding="async"
31
35
  />
32
36
  </picture>
@@ -6,6 +6,12 @@ import type { Scavold } from "../index";
6
6
 
7
7
  interface MenuProps {
8
8
 
9
+ /** Renders a named menu declared in `.cratly.config.yaml` instead of the page tree:
10
+ * the pages choosing it in their `menus` front matter, wherever they sit. `fromPath`
11
+ * and `fromRoot` then narrow the part of the site searched — `"{locale}"` keeps a
12
+ * multilingual site's menu to one language — and `from` is ignored. */
13
+ menu?: string;
14
+
9
15
  /** Selects the parent node by path, supporting a `{locale}` placeholder that is
10
16
  * replaced with the current page's locale at runtime. Takes priority over
11
17
  * `fromRoot` and `from` when set. Examples: `"de/footer"`, `"{locale}/footer"`. */
@@ -36,6 +42,7 @@ interface MenuProps {
36
42
  }
37
43
 
38
44
  const props = withDefaults( defineProps<MenuProps>(), {
45
+ menu: undefined,
39
46
  fromPath: undefined,
40
47
  fromRoot: undefined,
41
48
  from: 0,
@@ -45,9 +52,21 @@ const props = withDefaults( defineProps<MenuProps>(), {
45
52
  label: undefined,
46
53
  } );
47
54
 
48
- const { current, ancestorAtDepth, collectItems, resolveByPath } = useHierarchy();
55
+ const { hierarchy, current, ancestorAtDepth, collectItems, collectMenuItems, resolveByPath } = useHierarchy();
49
56
 
50
57
  const items = computed<Scavold.MenuItem[]>( () => {
58
+ if ( props.menu !== undefined ) {
59
+ let scope: Scavold.HierarchyNode | undefined = hierarchy.value;
60
+
61
+ if ( props.fromPath !== undefined ) {
62
+ scope = resolveByPath( props.fromPath );
63
+ } else if ( props.fromRoot !== undefined ) {
64
+ scope = ancestorAtDepth( current.value, props.fromRoot );
65
+ }
66
+
67
+ return collectMenuItems( props.menu, scope, props.depth, props.activeOnly, props.expand );
68
+ }
69
+
51
70
  let parent: Scavold.HierarchyNode | undefined;
52
71
 
53
72
  if ( props.fromPath !== undefined ) {
@@ -1,39 +1,26 @@
1
1
  <script setup>
2
+ import { ref } from "vue";
3
+ import { useAutoplay } from "../composables/useAutoplay.js";
2
4
  import { useVideo, videoProps } from "../composables/useVideo.js";
3
5
 
4
6
  const props = defineProps( videoProps );
5
7
 
6
- const { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel } =
7
- useVideo( props );
8
+ const { src, poster, autoplay, loop, muted, preload, ariaLabel } = useVideo( props );
9
+
10
+ // Started and paused by script while on screen — see useAutoplay.
11
+ const video = ref( null );
12
+
13
+ useAutoplay( video, () => autoplay.value );
8
14
  </script>
9
15
 
10
16
  <template>
11
- <!-- overlay mode: the video is a background, the slot content sits on top -->
12
- <div v-if="overlay && src" class="scavold-video scavold-video--overlay">
13
- <video
14
- class="scavold-video__media"
15
- :src="src"
16
- :poster="poster || undefined"
17
- :autoplay="autoplay || undefined"
18
- :loop="loop || undefined"
19
- :muted="muted || undefined"
20
- :controls="controls || undefined"
21
- :preload="preload"
22
- :aria-label="ariaLabel"
23
- playsinline
24
- />
25
- <div class="scavold-video__overlay">
26
- <slot />
27
- </div>
28
- </div>
29
-
30
- <!-- default mode: plain inline player; the slot is fallback content -->
17
+ <!-- the slot is fallback content, and where <track> elements go -->
31
18
  <video
32
- v-else-if="src"
33
- class="scavold-video scavold-video__media"
19
+ v-if="src"
20
+ ref="video"
21
+ class="scavold-video"
34
22
  :src="src"
35
23
  :poster="poster || undefined"
36
- :autoplay="autoplay || undefined"
37
24
  :loop="loop || undefined"
38
25
  :muted="muted || undefined"
39
26
  :preload="preload"
@@ -41,34 +28,6 @@ const { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabe
41
28
  controls
42
29
  playsinline
43
30
  >
44
- <!-- <track kind="captions" src="..." srclang="..." label="..." /> -->
45
31
  <slot />
46
32
  </video>
47
33
  </template>
48
-
49
- <style scoped>
50
- /*
51
- * Functional layout only — visual styling (scrim, colours, alignment, min-height)
52
- * belongs to the consuming theme. The overlay stacks the video and the content in
53
- * a single grid cell so the content defines the block's height and the video
54
- * covers the same area behind it.
55
- */
56
- .scavold-video--overlay {
57
- position: relative;
58
- display: grid;
59
- overflow: hidden;
60
- }
61
-
62
- .scavold-video--overlay .scavold-video__media {
63
- grid-area: 1 / 1;
64
- width: 100%;
65
- height: 100%;
66
- object-fit: cover;
67
- }
68
-
69
- .scavold-video--overlay .scavold-video__overlay {
70
- grid-area: 1 / 1;
71
- position: relative;
72
- z-index: 1;
73
- }
74
- </style>
@@ -2,6 +2,7 @@ import { useData } from "vitepress";
2
2
  import { computed } from "vue";
3
3
  import { useL10n } from "@cepharum/vue3-i18n";
4
4
  import { hidesFrom } from "../lib/hide.js";
5
+ import { AUTO_MENU, appearsIn } from "../lib/menus.js";
5
6
  import { nodePath, pageHref } from "../lib/href.js";
6
7
  import type { Scavold } from "../index";
7
8
 
@@ -136,7 +137,7 @@ export function useHierarchy() {
136
137
  }
137
138
 
138
139
  return Object.values( parent.subs )
139
- .filter( node => !isHidden( node, "menu" ) )
140
+ .filter( node => appearsIn( node.frontmatter, AUTO_MENU ) )
140
141
  .map( node => {
141
142
  const shouldDescend = remaining !== 0 && (
142
143
  expand ||
@@ -154,6 +155,75 @@ export function useHierarchy() {
154
155
  } );
155
156
  };
156
157
 
158
+ /**
159
+ * Collects the items of a named menu: the pages below `scope` choosing that menu in
160
+ * their `menus` front matter, in page-tree order, wherever they sit among the folders.
161
+ *
162
+ * Below each of them, `remaining`, `activeOnly` and `expand` descend into sub-pages as
163
+ * collectItems() does. A page choosing the menu along with one of its ancestors is
164
+ * listed below that ancestor rather than a second time — unless the menu shows no
165
+ * sub-pages at all, which keeps it flat and lists the page beside its ancestor.
166
+ *
167
+ * @param name menu name as declared in .cratly.config.yaml
168
+ * @param scope the node whose descendants are searched
169
+ * @param remaining levels to descend below each listed page; -1 means unlimited
170
+ * @param activeOnly only expand children along the active path
171
+ * @param expand expand all nodes with children regardless of active path
172
+ */
173
+ const collectMenuItems = (
174
+ name: string,
175
+ scope: Scavold.HierarchyNode | undefined,
176
+ remaining: number,
177
+ activeOnly: boolean,
178
+ expand: boolean,
179
+ ): Scavold.MenuItem[] => {
180
+ const contains = ( items: Scavold.MenuItem[], node: Scavold.HierarchyNode ): boolean =>
181
+ items.some( item => item.node === node || contains( item.children, node ) );
182
+
183
+ const items: Scavold.MenuItem[] = [];
184
+
185
+ for ( const node of Object.values( scope?.subs ?? {} ) ) {
186
+ // Searched even below a page left out of the menu: choosing a menu is
187
+ // deliberate, and a folder that only groups pages is not a reason to drop it.
188
+ const listedBelow = collectMenuItems( name, node, remaining, activeOnly, expand );
189
+
190
+ if ( !appearsIn( node.frontmatter, name ) ) {
191
+ items.push( ...listedBelow );
192
+ continue;
193
+ }
194
+
195
+ const shouldDescend = remaining !== 0 && (
196
+ expand ||
197
+ ( activeOnly && isOnActivePath( node ) )
198
+ );
199
+
200
+ const children = shouldDescend
201
+ ? collectItems( node, remaining === -1 ? -1 : remaining - 1, activeOnly, expand )
202
+ : [];
203
+
204
+ // A menu showing no sub-pages stays flat: what was chosen below is listed beside
205
+ // its ancestor rather than dropped, or nested where the theme expects no list.
206
+ if ( remaining === 0 ) {
207
+ items.push( {
208
+ node,
209
+ active: isOnActivePath( node ),
210
+ current: node === current.value,
211
+ children: [],
212
+ }, ...listedBelow );
213
+ continue;
214
+ }
215
+
216
+ items.push( {
217
+ node,
218
+ active: isOnActivePath( node ),
219
+ current: node === current.value,
220
+ children: [ ...children, ...listedBelow.filter( item => !contains( children, item.node ) ) ],
221
+ } );
222
+ }
223
+
224
+ return items;
225
+ };
226
+
157
227
  /**
158
228
  * Collects the pages below a starting node as a flat array, in hierarchy order.
159
229
  * Unlike collectItems() this keeps no tree shape — a page list presents its
@@ -431,6 +501,7 @@ export function useHierarchy() {
431
501
  ancestorAtDepth,
432
502
  isOnActivePath,
433
503
  collectItems,
504
+ collectMenuItems,
434
505
  collectPages,
435
506
  collectAncestors,
436
507
  collectLocaleLinks,
@@ -0,0 +1,128 @@
1
+ import { onBeforeUnmount, onMounted } from "vue";
2
+
3
+ /** Share of an element that has to be on screen before its video starts. */
4
+ export const VISIBLE_SHARE = 0.5;
5
+
6
+ /** Thresholds the observer reports at, fine enough to catch the share above. */
7
+ const THRESHOLDS = Array.from( { length: 21 }, ( _, i ) => i / 20 );
8
+
9
+ /**
10
+ * Decides whether an observed element is on screen far enough to play: half of it,
11
+ * or — for an element higher than the window, which never gets half of itself on
12
+ * screen — half of the window.
13
+ *
14
+ * @param {{intersectionRect: DOMRectReadOnly, boundingClientRect: DOMRectReadOnly, rootBounds: DOMRectReadOnly|null}} entry
15
+ * @param {number} [share] required share
16
+ * @returns {boolean}
17
+ */
18
+ export function isVisibleEnough( { intersectionRect, boundingClientRect, rootBounds }, share = VISIBLE_SHARE ) {
19
+ const visible = intersectionRect.height;
20
+ const needed = share * Math.min( boundingClientRect.height, rootBounds?.height ?? Infinity );
21
+
22
+ return visible > 0 && visible >= needed;
23
+ }
24
+
25
+ /**
26
+ * Starts and pauses a muted video as it scrolls into and out of view, without
27
+ * overruling the visitor:
28
+ *
29
+ * - a video the visitor paused stays paused, even when scrolled back into view,
30
+ * until they start it again;
31
+ * - a video playing with sound is not paused when scrolled away — someone who turned
32
+ * it up is listening.
33
+ *
34
+ * @param {HTMLVideoElement} element
35
+ * @returns {{ setVisible: function(boolean): void, detach: function(): void }}
36
+ */
37
+ export function autoplayControl( element ) {
38
+ // What this control itself just asked the element to do, to tell the events it
39
+ // causes apart from the visitor's.
40
+ let own = null;
41
+ let heldByVisitor = false;
42
+
43
+ const onPlay = () => {
44
+ if ( own === "play" ) {
45
+ own = null;
46
+ } else {
47
+ heldByVisitor = false;
48
+ }
49
+ };
50
+
51
+ const onPause = () => {
52
+ if ( own === "pause" ) {
53
+ own = null;
54
+ } else {
55
+ heldByVisitor = true;
56
+ }
57
+ };
58
+
59
+ element.addEventListener( "play", onPlay );
60
+ element.addEventListener( "pause", onPause );
61
+
62
+ return {
63
+ setVisible( visible ) {
64
+ if ( visible ) {
65
+ if ( element.paused && !heldByVisitor ) {
66
+ own = "play";
67
+ element.muted = true;
68
+ element.play()?.catch( () => {
69
+ // Refused by the browser, e.g. in power saving mode; the visitor
70
+ // can still start it by hand.
71
+ own = null;
72
+ } );
73
+ }
74
+ } else if ( !element.paused && element.muted ) {
75
+ own = "pause";
76
+ element.pause();
77
+ }
78
+ },
79
+
80
+ detach() {
81
+ element.removeEventListener( "play", onPlay );
82
+ element.removeEventListener( "pause", onPause );
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Plays a video automatically while it is on screen, for components that offer
89
+ * autoplay. Nothing happens for visitors who ask for reduced motion: they find the
90
+ * video paused, with a way to start it.
91
+ *
92
+ * Deliberately not done with the `autoplay` attribute, which would start the video
93
+ * before hydration, regardless of both reduced motion and where it is on the page.
94
+ *
95
+ * @param {import("vue").Ref<HTMLVideoElement|null>} videoRef template ref of the video
96
+ * @param {function(): boolean} enabled whether the video is meant to play by itself
97
+ */
98
+ export function useAutoplay( videoRef, enabled ) {
99
+ let control = null;
100
+ let observer = null;
101
+
102
+ onMounted( () => {
103
+ const element = videoRef.value;
104
+
105
+ if ( !element || !enabled() || window.matchMedia?.( "(prefers-reduced-motion: reduce)" ).matches ) {
106
+ return;
107
+ }
108
+
109
+ control = autoplayControl( element );
110
+
111
+ if ( typeof IntersectionObserver === "undefined" ) {
112
+ control.setVisible( true );
113
+
114
+ return;
115
+ }
116
+
117
+ observer = new IntersectionObserver(
118
+ entries => control.setVisible( isVisibleEnough( entries[entries.length - 1] ) ),
119
+ { threshold: THRESHOLDS }
120
+ );
121
+ observer.observe( element );
122
+ } );
123
+
124
+ onBeforeUnmount( () => {
125
+ observer?.disconnect();
126
+ control?.detach();
127
+ } );
128
+ }