@abreen/tada 1.18.1 → 1.18.3
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 +2 -2
- package/bin/tada.ts +1 -1
- package/build/bundle.ts +3 -3
- package/init/content/labs/00/VowelCounter.java.md +1 -1
- package/package.json +1 -1
- package/schema/site.schema.json +1 -1
- package/src/_content.scss +1 -1
- package/src/_layout.scss +5 -3
- package/src/globals.ts +7 -0
- package/src/header/README.md +8 -2
- package/src/header/_index.scss +77 -30
- package/src/header/index.ts +142 -0
- package/src/navigate/_index.scss +31 -5
- package/src/navigate/runtime.ts +27 -65
- package/templates/_theme.scss +14 -12
- package/templates/_top.html +6 -1
- package/src/navigate/breadcrumbs.ts +0 -129
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ Create a new Tada site in a new directory. Prompts for:
|
|
|
109
109
|
- **Site title**: displayed in the header and `<title>` tag
|
|
110
110
|
- **Symbol**: short text (1-5 uppercase characters) shown in the logo and favicon
|
|
111
111
|
- **Theme color**: HSL color, e.g. `hsl(195 70% 40%)`
|
|
112
|
-
- **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `
|
|
112
|
+
- **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `33`)
|
|
113
113
|
- **Background tint amount**: percentage (0-100) of tint to apply (defaults to `100`)
|
|
114
114
|
- **Default time zone**: for `<time>` elements (defaults to your system zone)
|
|
115
115
|
- **Production base URL**: e.g. `https://example.edu`
|
|
@@ -272,7 +272,7 @@ vars:
|
|
|
272
272
|
| `logo` | *Optional*, image path relative to `public/`, replacing the header symbol |
|
|
273
273
|
| `favicon` | *Optional*, `.ico` path relative to `public/`, replacing generated favicons and the web app manifest |
|
|
274
274
|
| `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
|
|
275
|
-
| `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `
|
|
275
|
+
| `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `33`) |
|
|
276
276
|
| `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
|
|
277
277
|
| `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
|
|
278
278
|
| `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
|
package/bin/tada.ts
CHANGED
package/build/bundle.ts
CHANGED
|
@@ -52,12 +52,12 @@ function renderThemeScss(siteVariables: SiteVariables): string {
|
|
|
52
52
|
const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
|
|
53
53
|
const template = fs.readFileSync(templatePath, 'utf-8');
|
|
54
54
|
const theme = deriveTheme(siteVariables.themeColor);
|
|
55
|
-
const tintHue = siteVariables.tintHue ??
|
|
55
|
+
const tintHue = siteVariables.tintHue ?? 33;
|
|
56
56
|
const tintAmount = siteVariables.tintAmount ?? 100;
|
|
57
57
|
|
|
58
58
|
const linkHue = formatCssNumber(deriveLinkHue(tintHue));
|
|
59
|
-
const linkColor = `hsl(${linkHue}deg
|
|
60
|
-
const linkColorHover = `hsl(${linkHue}deg
|
|
59
|
+
const linkColor = `hsl(${linkHue}deg 66% 38%)`;
|
|
60
|
+
const linkColorHover = `hsl(${linkHue}deg 56% 49%)`;
|
|
61
61
|
const linkColorDark = `hsl(${linkHue}deg 50% 72%)`;
|
|
62
62
|
const linkColorHoverDark = `hsl(${linkHue}deg 40% 80%)`;
|
|
63
63
|
const traceLineActiveHue = formatCssNumber(deriveTraceLineActiveHue(tintHue));
|
|
@@ -18,7 +18,7 @@ The <dfn>standard in</dfn> (or `stdin`) is one of three streams available to a
|
|
|
18
18
|
running program. The other two streams are the standard out and standard error.
|
|
19
19
|
|
|
20
20
|
<figure>
|
|
21
|
-
<svg viewBox="
|
|
21
|
+
<svg viewBox="15 50 380 90" xmlns="http://www.w3.org/2000/svg" font-size="14" fill="none" style="margin:1rem auto;display:block;max-width:380px">
|
|
22
22
|
<defs>
|
|
23
23
|
<marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
|
|
24
24
|
<path d="M0,0 L8,4 L0,8 Z" stroke="currentColor" fill="currentColor"></path>
|
package/package.json
CHANGED
package/schema/site.schema.json
CHANGED
package/src/_content.scss
CHANGED
package/src/_layout.scss
CHANGED
|
@@ -152,14 +152,16 @@ body.literate main table {
|
|
|
152
152
|
.info {
|
|
153
153
|
@include user-select(none);
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
// Flex instead of a `1fr auto` grid: WebKit keeps the auto column's width
|
|
156
|
+
// after the font changes, which wraps the author avatar onto its own line.
|
|
157
|
+
display: flex;
|
|
157
158
|
align-items: baseline;
|
|
159
|
+
justify-content: space-between;
|
|
158
160
|
font-size: var(--font-size-smaller);
|
|
159
161
|
color: var(--fg2-color);
|
|
160
162
|
|
|
161
163
|
@media (width < 450px) {
|
|
162
|
-
|
|
164
|
+
flex-direction: column;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
aside.author {
|
package/src/globals.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface Globals {
|
|
|
11
11
|
createResizeObserver: (
|
|
12
12
|
callback: ResizeObserverCallback,
|
|
13
13
|
) => Pick<ResizeObserver, 'disconnect' | 'observe'>;
|
|
14
|
+
createIntersectionObserver: (
|
|
15
|
+
callback: IntersectionObserverCallback,
|
|
16
|
+
options?: IntersectionObserverInit,
|
|
17
|
+
) => Pick<IntersectionObserver, 'disconnect' | 'observe'>;
|
|
14
18
|
fetch: FetchFunction;
|
|
15
19
|
importModule: ImportModuleFunction;
|
|
16
20
|
isDocumentHidden: (document: Document) => boolean;
|
|
@@ -26,6 +30,9 @@ export const globals: Globals = {
|
|
|
26
30
|
createResizeObserver(callback) {
|
|
27
31
|
return new ResizeObserver(callback);
|
|
28
32
|
},
|
|
33
|
+
createIntersectionObserver(callback, options) {
|
|
34
|
+
return new IntersectionObserver(callback, options);
|
|
35
|
+
},
|
|
29
36
|
fetch(input: RequestInfo | URL, init?: RequestInit) {
|
|
30
37
|
return fetch(input, init);
|
|
31
38
|
},
|
package/src/header/README.md
CHANGED
|
@@ -5,6 +5,12 @@ and, where supported, animates its `<details>` content. The component's
|
|
|
5
5
|
JavaScript adds dismissal behavior for outside clicks, focus leaving the menu,
|
|
6
6
|
and the Escape key.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The component also rolls the header title between the site title and the page
|
|
9
|
+
title as the page heading scrolls under the header (see
|
|
10
|
+
[Header Title](../../spec/header-title.md)). The navigator calls
|
|
11
|
+
`swapHeaderTitle()` after each content swap so the header adopts the new page
|
|
12
|
+
title and shows the title for the new scroll position without rolling.
|
|
13
|
+
|
|
9
14
|
If JavaScript is turned off in the web browser, the `<details>` element is still
|
|
10
|
-
functional and its disclosure content opens immediately
|
|
15
|
+
functional and its disclosure content opens immediately, and the header shows
|
|
16
|
+
only the site title.
|
package/src/header/_index.scss
CHANGED
|
@@ -5,32 +5,20 @@
|
|
|
5
5
|
--header-menu-icon-size: 24px;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// The summary and the controls share one grid row, so the site title gets
|
|
9
|
+
// exactly the width the currently rendered controls leave behind.
|
|
8
10
|
header {
|
|
9
|
-
--header-search-reservation: 0px;
|
|
10
|
-
--header-to-top-reservation: 0px;
|
|
11
|
-
|
|
12
11
|
position: fixed;
|
|
13
12
|
top: 0;
|
|
14
13
|
z-index: 1000;
|
|
15
14
|
display: grid;
|
|
16
|
-
grid-template-columns: 1fr;
|
|
15
|
+
grid-template-columns: minmax(0, 1fr) max-content;
|
|
17
16
|
width: 100%;
|
|
17
|
+
container-type: inline-size;
|
|
18
18
|
background: var(--bg-color-translucent);
|
|
19
19
|
border-bottom: 1px solid var(--bg2-color);
|
|
20
20
|
backdrop-filter: var(--backdrop-filter);
|
|
21
21
|
view-transition-name: site-header;
|
|
22
|
-
|
|
23
|
-
&:has(.search-controls) {
|
|
24
|
-
--header-search-reservation: 200px;
|
|
25
|
-
|
|
26
|
-
@media (width <= 400px) {
|
|
27
|
-
--header-search-reservation: 0px;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
&:has(.to-top-container .is-visible) {
|
|
32
|
-
--header-to-top-reservation: 120px;
|
|
33
|
-
}
|
|
34
22
|
}
|
|
35
23
|
|
|
36
24
|
:root[data-contrast-preference='high'] header {
|
|
@@ -57,6 +45,7 @@ body:has(header details[open]) > .container::before {
|
|
|
57
45
|
}
|
|
58
46
|
|
|
59
47
|
header > details {
|
|
48
|
+
grid-area: 1 / 1;
|
|
60
49
|
min-width: 0;
|
|
61
50
|
}
|
|
62
51
|
|
|
@@ -65,9 +54,7 @@ header details summary {
|
|
|
65
54
|
display: flex;
|
|
66
55
|
align-items: center;
|
|
67
56
|
height: var(--header-collapsed-height);
|
|
68
|
-
padding: 0
|
|
69
|
-
calc(var(--header-search-reservation) + var(--header-to-top-reservation)) 0
|
|
70
|
-
0;
|
|
57
|
+
padding: 0;
|
|
71
58
|
cursor: pointer;
|
|
72
59
|
list-style-type: none;
|
|
73
60
|
|
|
@@ -78,7 +65,8 @@ header details summary {
|
|
|
78
65
|
background: var(--bg2-color);
|
|
79
66
|
}
|
|
80
67
|
|
|
81
|
-
&:hover .site-title
|
|
68
|
+
&:hover .site-title,
|
|
69
|
+
&:hover .header-page-title {
|
|
82
70
|
@include link-underline;
|
|
83
71
|
}
|
|
84
72
|
}
|
|
@@ -160,7 +148,9 @@ header details[open] summary .menu-icon-line-bottom {
|
|
|
160
148
|
|
|
161
149
|
:root.js header > details::details-content {
|
|
162
150
|
min-block-size: 0;
|
|
163
|
-
|
|
151
|
+
|
|
152
|
+
// Clip only the height animation; the navigation spans the full header
|
|
153
|
+
overflow: visible clip;
|
|
164
154
|
opacity: 0;
|
|
165
155
|
transform: translateY(-4px);
|
|
166
156
|
transition:
|
|
@@ -176,8 +166,12 @@ header details[open] summary .menu-icon-line-bottom {
|
|
|
176
166
|
}
|
|
177
167
|
}
|
|
178
168
|
|
|
169
|
+
// The details element sits in the first column; its navigation extends under
|
|
170
|
+
// the controls column to fill the header.
|
|
179
171
|
header .content {
|
|
180
172
|
position: relative;
|
|
173
|
+
box-sizing: border-box;
|
|
174
|
+
width: 100cqw;
|
|
181
175
|
}
|
|
182
176
|
|
|
183
177
|
header summary .logo {
|
|
@@ -201,36 +195,89 @@ header summary .logo-image {
|
|
|
201
195
|
object-fit: contain;
|
|
202
196
|
}
|
|
203
197
|
|
|
204
|
-
|
|
198
|
+
// The site title and page title sit on a reel, one header row each, behind a
|
|
199
|
+
// window one row tall. Scrolling past the page heading rolls the reel up to the
|
|
200
|
+
// page title; without JavaScript, only the site title is shown.
|
|
201
|
+
header summary .header-title {
|
|
205
202
|
flex: 1 1 0;
|
|
203
|
+
align-self: stretch;
|
|
206
204
|
min-width: 0;
|
|
207
|
-
margin: 0
|
|
205
|
+
margin: 0 var(--gap) 0 0;
|
|
208
206
|
overflow: hidden;
|
|
209
|
-
text-overflow: ellipsis;
|
|
210
207
|
|
|
211
208
|
// Values above 500 resolve to the supplied bold face when a custom family
|
|
212
209
|
// provides only fixed 400 and 700 weights.
|
|
213
210
|
font-weight: 600;
|
|
214
|
-
white-space: nowrap;
|
|
215
211
|
pointer-events: none;
|
|
216
212
|
}
|
|
217
213
|
|
|
214
|
+
// Feather the window's top and bottom edges so a title fades as it rolls out
|
|
215
|
+
:root.js header summary .header-title {
|
|
216
|
+
-webkit-mask-image: linear-gradient(
|
|
217
|
+
transparent,
|
|
218
|
+
#000 25%,
|
|
219
|
+
#000 75%,
|
|
220
|
+
transparent
|
|
221
|
+
);
|
|
222
|
+
mask-image: linear-gradient(transparent, #000 25%, #000 75%, transparent);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
header summary .header-title-reel {
|
|
226
|
+
display: block;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
header summary .header-title.is-page-title .header-title-reel {
|
|
230
|
+
transform: translateY(calc(-1 * var(--header-collapsed-height)));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
234
|
+
header summary .header-title-reel {
|
|
235
|
+
transition: transform 300ms cubic-bezier(0.2, 0, 0, 1);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
header summary .site-title,
|
|
240
|
+
header summary .header-page-title {
|
|
241
|
+
display: block;
|
|
242
|
+
height: var(--header-collapsed-height);
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
text-overflow: ellipsis;
|
|
245
|
+
line-height: var(--header-collapsed-height);
|
|
246
|
+
white-space: nowrap;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Code page titles keep their monospace font, without the inline code chip,
|
|
250
|
+
// like the file title heading
|
|
251
|
+
header summary .header-page-title :is(tt, code) {
|
|
252
|
+
padding: 0;
|
|
253
|
+
background: none;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
:root:not(.js) header summary .header-page-title {
|
|
257
|
+
display: none;
|
|
258
|
+
}
|
|
259
|
+
|
|
218
260
|
header > .header-controls {
|
|
219
|
-
position: absolute;
|
|
220
|
-
top: 0;
|
|
221
|
-
right: 0;
|
|
222
|
-
z-index: 1;
|
|
223
261
|
display: flex;
|
|
262
|
+
grid-area: 1 / 2;
|
|
263
|
+
gap: var(--gap);
|
|
224
264
|
align-items: center;
|
|
265
|
+
align-self: start;
|
|
225
266
|
height: var(--header-collapsed-height);
|
|
226
267
|
padding: 0 var(--gap) 0 0;
|
|
227
268
|
}
|
|
228
269
|
|
|
270
|
+
// Continue the summary's hover background behind the controls
|
|
271
|
+
@media (hover: hover) {
|
|
272
|
+
header:has(> details > summary:hover) > .header-controls {
|
|
273
|
+
background: var(--bg2-color);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
229
277
|
header .to-top-container {
|
|
230
278
|
display: none;
|
|
231
279
|
align-items: center;
|
|
232
280
|
height: var(--header-collapsed-height);
|
|
233
|
-
padding: 0 var(--gap) 0 0;
|
|
234
281
|
white-space: nowrap;
|
|
235
282
|
pointer-events: none;
|
|
236
283
|
|
package/src/header/index.ts
CHANGED
|
@@ -1,10 +1,150 @@
|
|
|
1
1
|
import { getElement } from '../util';
|
|
2
|
+
import { globals } from '../globals';
|
|
3
|
+
|
|
4
|
+
export type HeaderTitleState = 'site' | 'page';
|
|
5
|
+
|
|
6
|
+
export interface HeadingGeometry {
|
|
7
|
+
/** Bottom edge of the page heading block (breadcrumbs, h1, and info) */
|
|
8
|
+
blockBottom: number;
|
|
9
|
+
/** Top edge of the page's h1 */
|
|
10
|
+
titleTop: number;
|
|
11
|
+
/** Bottom edge of the collapsed header */
|
|
12
|
+
headerBottom: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HEADING_BLOCK_SELECTOR = '.title-and-info, .file-header';
|
|
16
|
+
const PAGE_TITLE_CLASS = 'is-page-title';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Show the page title once the whole heading block has scrolled under the
|
|
20
|
+
* header, and the site title once the h1 is entirely below the header again.
|
|
21
|
+
* In between, keep the current title so it does not flicker.
|
|
22
|
+
*/
|
|
23
|
+
export function nextHeaderTitleState(
|
|
24
|
+
current: HeaderTitleState,
|
|
25
|
+
geometry: HeadingGeometry | null,
|
|
26
|
+
): HeaderTitleState {
|
|
27
|
+
if (!geometry) {
|
|
28
|
+
return 'site';
|
|
29
|
+
}
|
|
30
|
+
if (geometry.blockBottom <= geometry.headerBottom) {
|
|
31
|
+
return 'page';
|
|
32
|
+
}
|
|
33
|
+
if (geometry.titleTop >= geometry.headerBottom) {
|
|
34
|
+
return 'site';
|
|
35
|
+
}
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getHeadingElements(document: Document) {
|
|
40
|
+
const block = document.querySelector(HEADING_BLOCK_SELECTOR);
|
|
41
|
+
const title = block?.querySelector('h1');
|
|
42
|
+
return block && title ? { block, title } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface TitleRoll {
|
|
46
|
+
swap(newDoc: Document): void;
|
|
47
|
+
disconnect(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let titleRoll: TitleRoll | null = null;
|
|
51
|
+
|
|
52
|
+
function getTitleElements(summary: HTMLElement) {
|
|
53
|
+
const container = summary.querySelector<HTMLElement>('.header-title');
|
|
54
|
+
const reel = container?.querySelector<HTMLElement>('.header-title-reel');
|
|
55
|
+
const pageTitle = reel?.querySelector<HTMLElement>('.header-page-title');
|
|
56
|
+
return container && reel && pageTitle ? { container, reel, pageTitle } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mountTitleRoll(
|
|
60
|
+
window: Window,
|
|
61
|
+
summary: HTMLElement,
|
|
62
|
+
): TitleRoll | null {
|
|
63
|
+
const elements = getTitleElements(summary);
|
|
64
|
+
if (!elements) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const { container, reel, pageTitle } = elements;
|
|
68
|
+
|
|
69
|
+
let state: HeaderTitleState = 'site';
|
|
70
|
+
|
|
71
|
+
function measure(): HeadingGeometry | null {
|
|
72
|
+
const heading = getHeadingElements(window.document);
|
|
73
|
+
// A hidden heading (for example, while presenting slides) has no boxes
|
|
74
|
+
if (!heading || heading.block.getClientRects().length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
blockBottom: heading.block.getBoundingClientRect().bottom,
|
|
79
|
+
titleTop: heading.title.getBoundingClientRect().top,
|
|
80
|
+
headerBottom: summary.getBoundingClientRect().bottom,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Scrolling rolls the reel; other changes, like navigation, switch at once
|
|
85
|
+
function update(roll: boolean) {
|
|
86
|
+
const next = nextHeaderTitleState(state, measure());
|
|
87
|
+
if (next !== state) {
|
|
88
|
+
state = next;
|
|
89
|
+
container.classList.toggle(PAGE_TITLE_CLASS, state === 'page');
|
|
90
|
+
}
|
|
91
|
+
if (!roll && typeof reel.getAnimations === 'function') {
|
|
92
|
+
for (const animation of reel.getAnimations()) {
|
|
93
|
+
animation.finish();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const observer = globals.createIntersectionObserver(() => update(true), {
|
|
99
|
+
rootMargin: `${-summary.getBoundingClientRect().bottom}px 0px 0px 0px`,
|
|
100
|
+
threshold: [0, 1],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function observeHeading() {
|
|
104
|
+
observer.disconnect();
|
|
105
|
+
const heading = getHeadingElements(window.document);
|
|
106
|
+
if (heading) {
|
|
107
|
+
observer.observe(heading.block);
|
|
108
|
+
observer.observe(heading.title);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
update(false);
|
|
113
|
+
observeHeading();
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
swap(newDoc) {
|
|
117
|
+
const newPageTitle = newDoc.querySelector('header .header-page-title');
|
|
118
|
+
if (newPageTitle) {
|
|
119
|
+
pageTitle.replaceChildren(
|
|
120
|
+
...window.document.importNode(newPageTitle, true).childNodes,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
observeHeading();
|
|
124
|
+
update(false);
|
|
125
|
+
},
|
|
126
|
+
disconnect() {
|
|
127
|
+
observer.disconnect();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Called by the navigator once a new page's content and scroll position are in
|
|
134
|
+
* place: adopt the new page title and show the title for the new position
|
|
135
|
+
* without rolling, so the page transition crossfades it.
|
|
136
|
+
*/
|
|
137
|
+
export function swapHeaderTitle(newDoc: Document): void {
|
|
138
|
+
titleRoll?.swap(newDoc);
|
|
139
|
+
}
|
|
2
140
|
|
|
3
141
|
export default (window: Window) => {
|
|
4
142
|
const header: HTMLElement = getElement(window.document, 'header');
|
|
5
143
|
const details = getElement(header, 'details') as HTMLDetailsElement;
|
|
6
144
|
const summary = getElement(details, 'summary') as HTMLElement;
|
|
7
145
|
|
|
146
|
+
titleRoll = mountTitleRoll(window, summary);
|
|
147
|
+
|
|
8
148
|
function close() {
|
|
9
149
|
details.open = false;
|
|
10
150
|
}
|
|
@@ -47,6 +187,8 @@ export default (window: Window) => {
|
|
|
47
187
|
details.addEventListener('focusout', handleDetailsFocusOut);
|
|
48
188
|
|
|
49
189
|
return () => {
|
|
190
|
+
titleRoll?.disconnect();
|
|
191
|
+
titleRoll = null;
|
|
50
192
|
window.removeEventListener('keydown', handleWindowKeyDown);
|
|
51
193
|
window.removeEventListener('click', handleWindowClick);
|
|
52
194
|
details.removeEventListener('click', handleDetailsClick);
|
package/src/navigate/_index.scss
CHANGED
|
@@ -43,19 +43,45 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Page heading
|
|
46
|
+
// Page heading parts: crossfade in place instead of sliding with the page
|
|
47
47
|
// view-transition-name is set via inline style by the navigator
|
|
48
|
-
// only when .title-and-info is visible in the viewport
|
|
49
|
-
|
|
48
|
+
// only when .title-and-info is visible in the viewport. All parts share one
|
|
49
|
+
// duration and easing so they move in step when the layout changes; the
|
|
50
|
+
// crossfades inherit the group timing.
|
|
51
|
+
::view-transition-group(page-breadcrumbs),
|
|
52
|
+
::view-transition-old(page-breadcrumbs),
|
|
53
|
+
::view-transition-new(page-breadcrumbs),
|
|
54
|
+
::view-transition-group(page-title),
|
|
50
55
|
::view-transition-old(page-title),
|
|
51
56
|
::view-transition-new(page-title),
|
|
57
|
+
::view-transition-group(page-info),
|
|
52
58
|
::view-transition-old(page-info),
|
|
53
59
|
::view-transition-new(page-info) {
|
|
54
60
|
animation-duration: $duration;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
::view-transition-group(page-breadcrumbs),
|
|
64
|
+
::view-transition-group(page-title),
|
|
65
|
+
::view-transition-group(page-info) {
|
|
66
|
+
animation-timing-function: $movement-easing;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Breadcrumbs: the whole trail is one group, so every item moves together when
|
|
70
|
+
// the layout changes. The default plus-lighter crossfade keeps unchanged items
|
|
71
|
+
// opaque while added or removed items fade. Snapshots keep their natural size
|
|
72
|
+
// so a change in trail width does not stretch the text.
|
|
73
|
+
::view-transition-old(page-breadcrumbs),
|
|
74
|
+
::view-transition-new(page-breadcrumbs) {
|
|
75
|
+
inline-size: auto;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The header stays in place. Parts that change between pages, such as the
|
|
79
|
+
// title shown in it, crossfade in step with the page heading.
|
|
80
|
+
::view-transition-group(site-header),
|
|
81
|
+
::view-transition-old(site-header),
|
|
82
|
+
::view-transition-new(site-header) {
|
|
83
|
+
animation-duration: $duration;
|
|
84
|
+
}
|
|
59
85
|
|
|
60
86
|
// The site-wide banner does not change between pages. Keep it out of the root
|
|
61
87
|
// slide and pin its named transition layer in place without a crossfade.
|
package/src/navigate/runtime.ts
CHANGED
|
@@ -5,22 +5,13 @@ import {
|
|
|
5
5
|
teardownPerPageComponents,
|
|
6
6
|
} from './lifecycle';
|
|
7
7
|
import { globals } from '../globals';
|
|
8
|
-
import {
|
|
9
|
-
cleanupBreadcrumbTransitionNames,
|
|
10
|
-
getBreadcrumbItems,
|
|
11
|
-
prepareBreadcrumbTransitionStyles,
|
|
12
|
-
setBreadcrumbTransitionNames,
|
|
13
|
-
sharedBreadcrumbCount,
|
|
14
|
-
} from './breadcrumbs';
|
|
8
|
+
import { swapHeaderTitle } from '../header';
|
|
15
9
|
|
|
16
10
|
export const NAVIGATION_EVENT = 'tada:navigation';
|
|
17
11
|
|
|
18
12
|
let currentAbortController: AbortController | null = null;
|
|
19
13
|
let historyIndex = 0;
|
|
20
14
|
let currentPath = '';
|
|
21
|
-
// Unlike the browser URL (which already changes on popstate), this tracks the
|
|
22
|
-
// page whose content is currently displayed, for relative breadcrumb identity.
|
|
23
|
-
let renderedPageUrl = '';
|
|
24
15
|
|
|
25
16
|
const scrollByIndex = new Map<number, number>();
|
|
26
17
|
const scrollByLocation = new Map<string, number>();
|
|
@@ -127,21 +118,29 @@ function dispatchNavigationEvent(
|
|
|
127
118
|
);
|
|
128
119
|
}
|
|
129
120
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
121
|
+
// Heading parts that get their own layer instead of sliding with the page. The
|
|
122
|
+
// breadcrumb trail is one group so every item, including the current one,
|
|
123
|
+
// moves together when the page layout changes.
|
|
124
|
+
const TITLE_TRANSITION_NAMES = [
|
|
125
|
+
['nav.breadcrumbs', 'page-breadcrumbs'],
|
|
126
|
+
['h1', 'page-title'],
|
|
127
|
+
['.info', 'page-info'],
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
function setTitleTransitionNames(titleEl: Element, enabled: boolean): void {
|
|
131
|
+
for (const [selector, name] of TITLE_TRANSITION_NAMES) {
|
|
132
|
+
const element = titleEl.querySelector<HTMLElement>(selector);
|
|
133
|
+
if (element) {
|
|
134
|
+
element.style.viewTransitionName = enabled ? name : '';
|
|
135
|
+
}
|
|
140
136
|
}
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cleanupViewTransitionNames(document: Document): void {
|
|
140
|
+
const titleEl = document.querySelector('.title-and-info');
|
|
141
|
+
if (titleEl) {
|
|
142
|
+
setTitleTransitionNames(titleEl, false);
|
|
143
143
|
}
|
|
144
|
-
cleanupBreadcrumbTransitionNames(document);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
function getLocationKey(window: Window): string {
|
|
@@ -177,7 +176,6 @@ export function initNavigation(window: Window): void {
|
|
|
177
176
|
scrollByLocation.clear();
|
|
178
177
|
window.history.scrollRestoration = 'manual';
|
|
179
178
|
currentPath = window.location.pathname + window.location.search;
|
|
180
|
-
renderedPageUrl = window.location.href;
|
|
181
179
|
scrollByIndex.set(historyIndex, window.scrollY);
|
|
182
180
|
scrollByLocation.set(getLocationKey(window), window.scrollY);
|
|
183
181
|
}
|
|
@@ -311,7 +309,6 @@ export async function navigateToUrl(
|
|
|
311
309
|
updateHead(document, newDoc);
|
|
312
310
|
mountAppearancePickerForPage(window);
|
|
313
311
|
currentPath = parsed.pathname + parsed.search;
|
|
314
|
-
renderedPageUrl = parsed.href;
|
|
315
312
|
|
|
316
313
|
if (pushHistory) {
|
|
317
314
|
historyIndex++;
|
|
@@ -331,6 +328,9 @@ export async function navigateToUrl(
|
|
|
331
328
|
|
|
332
329
|
scrollByIndex.set(historyIndex, window.scrollY);
|
|
333
330
|
scrollByLocation.set(getLocationKey(window), window.scrollY);
|
|
331
|
+
|
|
332
|
+
// Measured at the destination scroll position; the transition crossfades it
|
|
333
|
+
swapHeaderTitle(newDoc);
|
|
334
334
|
return true;
|
|
335
335
|
};
|
|
336
336
|
|
|
@@ -343,18 +343,6 @@ export async function navigateToUrl(
|
|
|
343
343
|
!prefersReducedMotion(window) &&
|
|
344
344
|
typeof transitionDoc.startViewTransition === 'function'
|
|
345
345
|
) {
|
|
346
|
-
const oldBreadcrumbItems = getBreadcrumbItems(document, renderedPageUrl);
|
|
347
|
-
const newBreadcrumbItems = getBreadcrumbItems(newDoc, parsed.href);
|
|
348
|
-
const sharedCount = sharedBreadcrumbCount(
|
|
349
|
-
oldBreadcrumbItems,
|
|
350
|
-
newBreadcrumbItems,
|
|
351
|
-
);
|
|
352
|
-
prepareBreadcrumbTransitionStyles(
|
|
353
|
-
document,
|
|
354
|
-
oldBreadcrumbItems.length,
|
|
355
|
-
newBreadcrumbItems.length,
|
|
356
|
-
sharedCount,
|
|
357
|
-
);
|
|
358
346
|
const titleEl = document.querySelector('.title-and-info');
|
|
359
347
|
const headerHeight =
|
|
360
348
|
document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
|
@@ -362,20 +350,7 @@ export async function navigateToUrl(
|
|
|
362
350
|
titleEl != null && titleEl.getBoundingClientRect().top >= headerHeight;
|
|
363
351
|
|
|
364
352
|
if (titleVisible) {
|
|
365
|
-
|
|
366
|
-
const info = titleEl.querySelector('.info') as HTMLElement | null;
|
|
367
|
-
if (h1) {
|
|
368
|
-
h1.style.viewTransitionName = 'page-title';
|
|
369
|
-
}
|
|
370
|
-
if (info) {
|
|
371
|
-
info.style.viewTransitionName = 'page-info';
|
|
372
|
-
}
|
|
373
|
-
setBreadcrumbTransitionNames(
|
|
374
|
-
document,
|
|
375
|
-
renderedPageUrl,
|
|
376
|
-
sharedCount,
|
|
377
|
-
'leave',
|
|
378
|
-
);
|
|
353
|
+
setTitleTransitionNames(titleEl, true);
|
|
379
354
|
}
|
|
380
355
|
|
|
381
356
|
document.documentElement.classList.add(
|
|
@@ -395,20 +370,7 @@ export async function navigateToUrl(
|
|
|
395
370
|
newTitleEl.getBoundingClientRect().top >= newHeaderHeight;
|
|
396
371
|
|
|
397
372
|
if (newTitleVisible && newTitleEl) {
|
|
398
|
-
|
|
399
|
-
const info = newTitleEl.querySelector('.info') as HTMLElement | null;
|
|
400
|
-
if (h1) {
|
|
401
|
-
h1.style.viewTransitionName = 'page-title';
|
|
402
|
-
}
|
|
403
|
-
if (info) {
|
|
404
|
-
info.style.viewTransitionName = 'page-info';
|
|
405
|
-
}
|
|
406
|
-
setBreadcrumbTransitionNames(
|
|
407
|
-
document,
|
|
408
|
-
renderedPageUrl,
|
|
409
|
-
sharedCount,
|
|
410
|
-
'enter',
|
|
411
|
-
);
|
|
373
|
+
setTitleTransitionNames(newTitleEl, true);
|
|
412
374
|
}
|
|
413
375
|
});
|
|
414
376
|
|
package/templates/_theme.scss
CHANGED
|
@@ -21,26 +21,28 @@
|
|
|
21
21
|
--note-bg-color: hsl(209deg 95% 75%);
|
|
22
22
|
|
|
23
23
|
// Main foreground color (the color of most text)
|
|
24
|
-
--fg-color: hsl(var(--tint-hue) calc(
|
|
24
|
+
--fg-color: hsl(var(--tint-hue) calc(30% * var(--tint-amount)) 8%);
|
|
25
25
|
--fg-color-translucent: hsl(
|
|
26
26
|
var(--tint-hue) calc(70% * var(--tint-amount)) 8% / 33%
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
// Secondary foreground color (lighter)
|
|
30
|
-
--fg2-color: hsl(var(--tint-hue) calc(
|
|
30
|
+
--fg2-color: hsl(var(--tint-hue) calc(8% * var(--tint-amount)) 45%);
|
|
31
31
|
--link-color: <%= linkColor %>;
|
|
32
32
|
--link-color-hover: <%= linkColorHover %>;
|
|
33
33
|
|
|
34
34
|
// Main background color (the color of the page background)
|
|
35
|
-
--bg-color: hsl(var(--tint-hue) calc(
|
|
35
|
+
--bg-color: hsl(var(--tint-hue) calc(65% * var(--tint-amount)) 97%);
|
|
36
|
+
|
|
37
|
+
// Same saturation as --bg-color, so frosting doesn't add color to the page
|
|
36
38
|
--bg-color-translucent: hsl(
|
|
37
|
-
var(--tint-hue) calc(
|
|
39
|
+
var(--tint-hue) calc(65% * var(--tint-amount)) 95% / 66%
|
|
38
40
|
);
|
|
39
41
|
|
|
40
42
|
// Secondary background color (lighter)
|
|
41
|
-
--bg2-color: hsl(var(--tint-hue) calc(
|
|
43
|
+
--bg2-color: hsl(var(--tint-hue) calc(55% * var(--tint-amount)) 92%);
|
|
42
44
|
--bg2-color-translucent: hsl(
|
|
43
|
-
var(--tint-hue) calc(
|
|
45
|
+
var(--tint-hue) calc(55% * var(--tint-amount)) 92% / 66%
|
|
44
46
|
);
|
|
45
47
|
--bg-trace-line-active: <%= bgTraceLineActive %>;
|
|
46
48
|
--box-shadow: 0 2px 9px
|
|
@@ -108,20 +110,20 @@
|
|
|
108
110
|
--theme-color: <%= themeColorDark %>;
|
|
109
111
|
--theme-color-text: <%= themeColorTextDark %>;
|
|
110
112
|
--text-on-theme: <%= textOnThemeDark %>;
|
|
111
|
-
--fg-color: hsl(var(--tint-hue) calc(
|
|
113
|
+
--fg-color: hsl(var(--tint-hue) calc(40% * var(--tint-amount)) 92%);
|
|
112
114
|
--fg-color-translucent: hsl(
|
|
113
115
|
var(--tint-hue) calc(85% * var(--tint-amount)) 90% / 33%
|
|
114
116
|
);
|
|
115
|
-
--fg2-color: hsl(var(--tint-hue) calc(
|
|
117
|
+
--fg2-color: hsl(var(--tint-hue) calc(10% * var(--tint-amount)) 60%);
|
|
116
118
|
--link-color: <%= linkColorDark %>;
|
|
117
119
|
--link-color-hover: <%= linkColorHoverDark %>;
|
|
118
|
-
--bg-color: hsl(var(--tint-hue) calc(
|
|
120
|
+
--bg-color: hsl(var(--tint-hue) calc(35% * var(--tint-amount)) 5%);
|
|
119
121
|
--bg-color-translucent: hsl(
|
|
120
|
-
var(--tint-hue) calc(
|
|
122
|
+
var(--tint-hue) calc(35% * var(--tint-amount)) 10% / 66%
|
|
121
123
|
);
|
|
122
|
-
--bg2-color: hsl(var(--tint-hue) calc(
|
|
124
|
+
--bg2-color: hsl(var(--tint-hue) calc(40% * var(--tint-amount)) 12%);
|
|
123
125
|
--bg2-color-translucent: hsl(
|
|
124
|
-
var(--tint-hue) calc(
|
|
126
|
+
var(--tint-hue) calc(40% * var(--tint-amount)) 12% / 66%
|
|
125
127
|
);
|
|
126
128
|
--bg-trace-line-active: <%= bgTraceLineActiveDark %>;
|
|
127
129
|
--box-shadow: 0 3px 8px
|
package/templates/_top.html
CHANGED
|
@@ -220,7 +220,12 @@
|
|
|
220
220
|
<path class="menu-icon-line menu-icon-line-bottom" d="M3 18h18" />
|
|
221
221
|
</svg>
|
|
222
222
|
<% if (site.logo) { %><img class="logo-image" src="/<%- encodePublicAssetPath(site.logo) %>" alt="" aria-hidden="true" /><% } else if (site.symbol) { %><span class="logo" aria-hidden="true"><%- site.symbol %></span><% } %>
|
|
223
|
-
<span class="
|
|
223
|
+
<span class="header-title" aria-hidden="true">
|
|
224
|
+
<span class="header-title-reel">
|
|
225
|
+
<span class="site-title"><%- site.title %></span>
|
|
226
|
+
<span class="header-page-title"><%= page.titleHtml %></span>
|
|
227
|
+
</span>
|
|
228
|
+
</span>
|
|
224
229
|
</summary>
|
|
225
230
|
<div class="content">
|
|
226
231
|
<%= render('_nav.html') %>
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
interface BreadcrumbIdentity {
|
|
2
|
-
label: string;
|
|
3
|
-
url: string;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
interface BreadcrumbItem extends BreadcrumbIdentity {
|
|
7
|
-
element: HTMLElement;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function breadcrumbPageKey(href: string, pageUrl: string): string {
|
|
11
|
-
const url = new URL(href, pageUrl);
|
|
12
|
-
// Fragments identify positions on the same page; query strings can identify
|
|
13
|
-
// different content. URL serialization also resolves relative paths/spaces.
|
|
14
|
-
return url.origin + url.pathname + url.search;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function sharedBreadcrumbCount(
|
|
18
|
-
oldItems: readonly BreadcrumbIdentity[],
|
|
19
|
-
newItems: readonly BreadcrumbIdentity[],
|
|
20
|
-
): number {
|
|
21
|
-
let count = 0;
|
|
22
|
-
while (
|
|
23
|
-
count < Math.min(oldItems.length, newItems.length) &&
|
|
24
|
-
oldItems[count].url === newItems[count].url &&
|
|
25
|
-
oldItems[count].label === newItems[count].label
|
|
26
|
-
) {
|
|
27
|
-
count++;
|
|
28
|
-
}
|
|
29
|
-
return count;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function getBreadcrumbItems(
|
|
33
|
-
document: Document,
|
|
34
|
-
pageUrl: string,
|
|
35
|
-
): BreadcrumbItem[] {
|
|
36
|
-
return Array.from(
|
|
37
|
-
document.querySelectorAll<HTMLElement>('nav.breadcrumbs ol > li'),
|
|
38
|
-
).flatMap(element => {
|
|
39
|
-
const text = element.querySelector('a, [aria-current="page"]');
|
|
40
|
-
if (!text) {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
const href = text.getAttribute('href') ?? pageUrl;
|
|
44
|
-
return [
|
|
45
|
-
{
|
|
46
|
-
element,
|
|
47
|
-
label: (text.textContent ?? '').trim().replace(/\s+/g, ' '),
|
|
48
|
-
url: breadcrumbPageKey(href, pageUrl),
|
|
49
|
-
},
|
|
50
|
-
];
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function setBreadcrumbTransitionNames(
|
|
55
|
-
document: Document,
|
|
56
|
-
pageUrl: string,
|
|
57
|
-
sharedCount: number,
|
|
58
|
-
side: 'enter' | 'leave',
|
|
59
|
-
): void {
|
|
60
|
-
const breadcrumb = document.querySelector<HTMLElement>('nav.breadcrumbs');
|
|
61
|
-
if (!breadcrumb) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
getBreadcrumbItems(document, pageUrl).forEach((item, index) => {
|
|
65
|
-
const kind = index < sharedCount ? 'shared' : side;
|
|
66
|
-
item.element.style.viewTransitionName = `page-breadcrumb-${kind}-${index}`;
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function prepareBreadcrumbTransitionStyles(
|
|
71
|
-
document: Document,
|
|
72
|
-
oldCount: number,
|
|
73
|
-
newCount: number,
|
|
74
|
-
sharedCount: number,
|
|
75
|
-
): void {
|
|
76
|
-
if (!oldCount && !newCount) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const style = document.createElement('style');
|
|
80
|
-
style.setAttribute('data-breadcrumb-transition-styles', '');
|
|
81
|
-
const rules: string[] = [];
|
|
82
|
-
const duration = 'var(--page-transition-duration)';
|
|
83
|
-
for (let index = 0; index < sharedCount; index++) {
|
|
84
|
-
const name = `page-breadcrumb-shared-${index}`;
|
|
85
|
-
rules.push(`
|
|
86
|
-
::view-transition-group(${name}) {
|
|
87
|
-
animation-duration: ${duration};
|
|
88
|
-
animation-timing-function: var(--page-transition-movement-easing);
|
|
89
|
-
}
|
|
90
|
-
::view-transition-old(${name}), ::view-transition-new(${name}) {
|
|
91
|
-
mix-blend-mode: normal;
|
|
92
|
-
animation: none;
|
|
93
|
-
}
|
|
94
|
-
::view-transition-old(${name}) { display: none; }
|
|
95
|
-
::view-transition-old(${name}):only-child {
|
|
96
|
-
display: block;
|
|
97
|
-
animation: page-fade-out ${duration} ease-in both;
|
|
98
|
-
}
|
|
99
|
-
::view-transition-new(${name}):only-child {
|
|
100
|
-
animation: page-fade-in ${duration} ease-out both;
|
|
101
|
-
}
|
|
102
|
-
`);
|
|
103
|
-
}
|
|
104
|
-
for (let index = sharedCount; index < oldCount; index++) {
|
|
105
|
-
rules.push(`::view-transition-old(page-breadcrumb-leave-${index}) {
|
|
106
|
-
mix-blend-mode: normal;
|
|
107
|
-
animation: page-fade-out ${duration} ease-in both;
|
|
108
|
-
}`);
|
|
109
|
-
}
|
|
110
|
-
for (let index = sharedCount; index < newCount; index++) {
|
|
111
|
-
rules.push(`::view-transition-new(page-breadcrumb-enter-${index}) {
|
|
112
|
-
mix-blend-mode: normal;
|
|
113
|
-
animation: page-fade-in ${duration} ease-out both;
|
|
114
|
-
}`);
|
|
115
|
-
}
|
|
116
|
-
// Exact names keep these rules compatible with the bundled CSS parser, which
|
|
117
|
-
// does not yet accept view-transition-class selectors. Names use indices only.
|
|
118
|
-
style.textContent = rules.join('\n');
|
|
119
|
-
document.head.appendChild(style);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function cleanupBreadcrumbTransitionNames(document: Document): void {
|
|
123
|
-
for (const element of document.querySelectorAll<HTMLElement>(
|
|
124
|
-
'nav.breadcrumbs, nav.breadcrumbs li',
|
|
125
|
-
)) {
|
|
126
|
-
element.style.removeProperty('view-transition-name');
|
|
127
|
-
}
|
|
128
|
-
document.querySelector('[data-breadcrumb-transition-styles]')?.remove();
|
|
129
|
-
}
|