@gemboss/website-ui 0.7.0 → 0.9.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/AGENTS.md +18 -3
- package/CHANGELOG.md +129 -0
- package/README.md +8 -8
- package/bin/lib/check.mjs +72 -1
- package/css/effects.css +71 -0
- package/css/header.css +176 -0
- package/css/tokens.css +7 -0
- package/demo/header.html +356 -0
- package/demo/index.html +2 -0
- package/demo/spotlight.html +291 -0
- package/docs/components.md +116 -4
- package/docs/markup-contract.md +135 -5
- package/docs/theming.md +9 -2
- package/js/website.js +168 -4
- package/js/website.mjs +208 -4
- package/package.json +1 -1
- package/skill/SKILL.md +6 -0
- package/system.json +13 -1
- package/templates/header.html +34 -6
- package/website.css +256 -1
- package/website.min.css +1 -1
package/docs/markup-contract.md
CHANGED
|
@@ -28,7 +28,8 @@ BEM-ish: `.gw-block`, `.gw-block__part`, `.gw-block--variant`.
|
|
|
28
28
|
|
|
29
29
|
One script wires every part below: the mega menu, the mobile menu, the theme toggle, reveal, the header's scroll
|
|
30
30
|
state, the current page, the segmented control, the busy button, an article's contents list and the scroll box of its
|
|
31
|
-
prose tables (0.5.0),
|
|
31
|
+
prose tables (0.5.0), the footer's folds (0.7.0), the mobile menu's drill-down and the language switcher (0.8.0), and
|
|
32
|
+
the spotlight (0.9.0). It comes in two entries built from one source
|
|
32
33
|
(`js/website.mjs`; `scripts/build.mjs` writes `js/website.js` from it and `--check` fails when they differ):
|
|
33
34
|
|
|
34
35
|
| entry | file | how it starts |
|
|
@@ -43,9 +44,9 @@ prose tables (0.5.0), and the footer's folds (0.7.0). It comes in two entries bu
|
|
|
43
44
|
to the page (Escape, the outside click, the busy-button guard, the system theme, the scroll) are installed once.
|
|
44
45
|
- **`destroy(root = document)`** takes back what `init` wired under `root`: its listeners and its marks. An open
|
|
45
46
|
mega menu or mobile menu is closed first, so an unmounted menu cannot leave the page locked.
|
|
46
|
-
- **`version`**: `'0.
|
|
47
|
-
- **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `
|
|
48
|
-
`segmented`, `toc`, `table`, `fold`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
|
|
47
|
+
- **`version`**: `'0.9.0'`. The classic script exposes the three as `window.gwWebsite`.
|
|
48
|
+
- **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `lang`, `theme`, `reveal`,
|
|
49
|
+
`header`, `segmented`, `toc`, `table`, `fold`, `spot`). A drill-down menu is wired with its toggle (`menu`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
|
|
49
50
|
is still wired.
|
|
50
51
|
- **One copy per page.** The first copy that runs owns the page (`window.gwWebsite`); another one (the classic
|
|
51
52
|
script next to a bundle, two bundles) hands its calls to it, so nothing is wired twice.
|
|
@@ -94,11 +95,13 @@ The script tells the page's own code what changed. Every event is a `CustomEvent
|
|
|
94
95
|
| `gw:mega` | the trigger (`[data-gw-mega]`) | `{ id, open }`: the panel's `id`, `true` when it opened | a panel opens or closes: click, hover, Escape, a click outside, focus leaving, a link inside, `destroy` of an open one. Moving to another menu fires a close, then an open |
|
|
95
96
|
| `gw:theme` | `<html>` | `{ theme: 'light' \| 'dark' }`: the theme the page now shows | a press on a theme toggle; also a change of the system's scheme while the page follows it (no `data-theme`) |
|
|
96
97
|
| `gw:segmented` | the group (`[data-gw-segmented]`) | `{ name, value }`: the group's name, the chosen option's `value` | the reader chooses an option (click, arrows, Home, End) |
|
|
98
|
+
| `gw:menu` | the mobile menu (`.gw-mobile-menu`) | `{ open, level }`: `true` while it is open; `level` is the title of the drill-down level in view, `''` for the top level (and for a 0.7.0 menu) | the menu opens or closes (the toggle, Escape, a link inside, resizing past 1080px, `destroy` of an open one), and a drill-down menu moves to a level or back to the top. Closing from a level fires only the close (0.8.0) |
|
|
97
99
|
| `gw:toc` | the contents (`[data-gw-toc]`) | `{ id }`: the id of the heading whose section is now marked, `''` above the first one | the section in view changes: scrolling, a contents link, a jump. Also once after load when the page opens inside a section (a link to `#id`): the observer reports after `init`, never during it |
|
|
98
100
|
|
|
99
101
|
```js
|
|
100
102
|
document.addEventListener('gw:theme', function (e) { chart.setTheme(e.detail.theme); });
|
|
101
103
|
document.addEventListener('gw:toc', function (e) { analytics.track('section', e.detail.id); });
|
|
104
|
+
document.addEventListener('gw:menu', function (e) { if (e.detail.level) analytics.track('menu', e.detail.level); });
|
|
102
105
|
```
|
|
103
106
|
|
|
104
107
|
## Header
|
|
@@ -125,13 +128,14 @@ document.addEventListener('gw:toc', function (e) { analytics.track('section', e.
|
|
|
125
128
|
</nav>
|
|
126
129
|
<div class="gw-header__actions">
|
|
127
130
|
<button class="gw-theme-toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme">svg</button>
|
|
131
|
+
<div class="gw-lang" data-gw-lang hidden> … </div> <!-- optional: the language switcher (0.8.0, below) -->
|
|
128
132
|
<a class="gw-nav__link gw-header__signin" href="…">Sign in</a>
|
|
129
133
|
<a class="gw-btn gw-btn--secondary gw-btn--sm" href="…">Book a demo</a>
|
|
130
134
|
<a class="gw-btn gw-btn--sm" href="…">Start free</a>
|
|
131
135
|
<button class="gw-menu-toggle" type="button" aria-expanded="false" aria-controls="gw-mobile-menu" data-gw-menu-toggle aria-label="Open menu">svg</button>
|
|
132
136
|
</div>
|
|
133
137
|
</div>
|
|
134
|
-
<div class="gw-mobile-menu" id="gw-mobile-menu" hidden> …the same links as lists, and the actions… </div>
|
|
138
|
+
<div class="gw-mobile-menu" id="gw-mobile-menu" data-gw-menu?="drill" hidden> …the same links as lists, and the actions… </div>
|
|
135
139
|
</header>
|
|
136
140
|
```
|
|
137
141
|
|
|
@@ -159,6 +163,99 @@ Script behaviour:
|
|
|
159
163
|
- **Current page**: the script marks `aria-current="page"` on `.gw-nav__link` / `.gw-mega__link` and the mobile
|
|
160
164
|
menu's links whose `href` pathname equals `location.pathname` (when the markup did not already). Each `init` takes
|
|
161
165
|
its own marks back first, so after a client-side navigation they follow the new path; a mark in the markup stays.
|
|
166
|
+
- **`gw:menu`** (0.8.0): opening and closing a mobile menu fire `gw:menu` on it (`{ open, level: '' }`; Events above).
|
|
167
|
+
|
|
168
|
+
### Drill-down mobile menu (0.8.0)
|
|
169
|
+
|
|
170
|
+
Opt-in on the sheet, `data-gw-menu="drill"`; the markup is the 0.7.0 menu plus a head in each group. The groups, the
|
|
171
|
+
top-level links and the actions are children of the sheet's `<nav>`.
|
|
172
|
+
|
|
173
|
+
```html
|
|
174
|
+
<div class="gw-mobile-menu" id="gw-mobile-menu" data-gw-menu="drill" hidden>
|
|
175
|
+
<nav class="gw-wrap" aria-label="Main, mobile">
|
|
176
|
+
<details class="gw-mobile-menu__group">
|
|
177
|
+
<summary>Product</summary>
|
|
178
|
+
<div class="gw-mobile-menu__head"><button class="gw-mobile-menu__back" type="button">Back</button><h2 class="gw-mobile-menu__title" tabindex="-1">Product</h2></div>
|
|
179
|
+
<p class="gw-mobile-menu__heading">Build</p> <!-- optional -->
|
|
180
|
+
<ul class="gw-mobile-menu__list">
|
|
181
|
+
<li><a class="gw-mobile-menu__link" href="/funnel"><span class="gw-mobile-menu__icon" aria-hidden="true">svg</span><span><b>Funnel builder</b><i>Angles, advertorials, offers</i></span></a></li>
|
|
182
|
+
<li><a class="gw-mobile-menu__link" href="/data">Analytics & data</a></li> <!-- the icon and the line are optional -->
|
|
183
|
+
</ul>
|
|
184
|
+
<a class="gw-mobile-menu__all" href="/product">All of Product</a> <!-- optional -->
|
|
185
|
+
</details>
|
|
186
|
+
<a class="gw-mobile-menu__top" href="/pricing">Pricing</a>
|
|
187
|
+
<div class="gw-mobile-menu__actions"> <!-- the bar at the bottom -->
|
|
188
|
+
<div class="gw-lang" data-gw-lang hidden> … </div> <!-- optional: first line, left -->
|
|
189
|
+
<a class="gw-btn gw-btn--lg gw-btn--block" href="…">Start free</a>
|
|
190
|
+
<a class="gw-btn gw-btn--secondary gw-btn--lg gw-btn--block" href="…">Book a demo</a>
|
|
191
|
+
<a class="gw-mobile-menu__link" href="…">Sign in</a> <!-- first line, right -->
|
|
192
|
+
</div>
|
|
193
|
+
</nav>
|
|
194
|
+
</div>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- **CSS, from the attribute** (works before and without the script): the sheet is a column with no bottom padding,
|
|
198
|
+
clipped sideways; top-level rows of 64px with 24px words; a level's links 56px with a hairline, their `<b>` and
|
|
199
|
+
`<i>`, the 32px `__icon` tile, the muted `__heading`; the actions are a bar that stays at the bottom of the sheet
|
|
200
|
+
(`position: sticky`): what is not a `.gw-btn` on the first line (two things at most), the last of those on the right,
|
|
201
|
+
the buttons side by side on the second line. Without the script the groups are `<details>` that open in place (the 0.7.0 menu), their
|
|
202
|
+
chevron pointing down, and the heads are not drawn.
|
|
203
|
+
- **Script** (`.is-drill` on the sheet once wired, with the toggle's `menu` record). Each group that has a `<summary>`
|
|
204
|
+
and a `.gw-mobile-menu__title` is a level; the script closes them all (and gives the markup's `open` back on
|
|
205
|
+
`destroy`). A press on a summary (click, tap, Enter, Space; its default is cancelled) opens that group alone, puts
|
|
206
|
+
`.is-drilled` on the sheet (the CSS then draws only that level and its head, and hides its summary), scrolls the
|
|
207
|
+
sheet to its top, moves the focus to the title and fires `gw:menu` (`{ open: true, level: <the title's words> }`).
|
|
208
|
+
A press on `.gw-mobile-menu__back` shows the top level (`.is-back` on the sheet, for the slide back), puts the focus
|
|
209
|
+
on the summary it came from and fires `gw:menu` (`level: ''`). Closing the sheet (the toggle, Escape from anywhere in
|
|
210
|
+
it, a link, resizing past 1080px, `destroy`) shows the top level again without an event of its own.
|
|
211
|
+
- **While it is open**: Tab on the sheet's last shown stop goes to the toggle, Shift+Tab on the toggle to that stop, and
|
|
212
|
+
a focus found outside comes back in (the trap; a 0.7.0 menu has none). The page is locked as in 0.7.0
|
|
213
|
+
(`html.is-menu-open`). As it opens, the script sets `--gw-menu-top` on the sheet to where the header ends, and the
|
|
214
|
+
header stays `sticky` (not `fixed`), so the sheet starts under the header and an announcement bar above it stays.
|
|
215
|
+
- **Motion**: a level slides in from the right, the sections back from the left (`transform` and `opacity`, 0.24s, the
|
|
216
|
+
kit's ease), only under `prefers-reduced-motion: no-preference`.
|
|
217
|
+
- `check` reports a group of a drill menu that is not a `<details>`, has no `<summary>`, no back button, no title or a
|
|
218
|
+
title without `tabindex="-1"`, and `data-gw-menu` with another value (`menu-markup`).
|
|
219
|
+
|
|
220
|
+
### Language switcher (0.8.0)
|
|
221
|
+
|
|
222
|
+
```html
|
|
223
|
+
<head>
|
|
224
|
+
<link rel="alternate" hreflang="en" href="https://example.com/pricing">
|
|
225
|
+
<link rel="alternate" hreflang="vi" href="https://example.com/vi/pricing">
|
|
226
|
+
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing">
|
|
227
|
+
</head>
|
|
228
|
+
…
|
|
229
|
+
<div class="gw-lang" data-gw-lang hidden>
|
|
230
|
+
<button class="gw-lang__btn" type="button" aria-expanded="false" aria-controls="gw-lang-menu" data-gw-label-language="Language"><svg aria-hidden="true">globe</svg><span class="gw-lang__short"></span></button>
|
|
231
|
+
<ul class="gw-lang__menu" id="gw-lang-menu" hidden></ul>
|
|
232
|
+
</div>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- **Where**: in `.gw-header__actions`, before Sign in (drawn from 1080px), and a second one, with its own list `id`, in
|
|
236
|
+
the mobile menu's `.gw-mobile-menu__actions` (its list opens upward there). Both are filled the same way.
|
|
237
|
+
- **The languages are the head's** `<link rel="alternate" hreflang href>`, not `x-default`, the first link of each
|
|
238
|
+
`hreflang`: the links search engines read. The current language is the one whose `hreflang` is `<html lang>`, else the
|
|
239
|
+
first with the same base language (`<html lang="zh">` is `zh-Hans`). The script writes each other language into the
|
|
240
|
+
list as `<li><a class="gw-lang__link" href hreflang lang>Name</a></li>`, the name in its own language: the `<link>`'s
|
|
241
|
+
`title`, else the kit's (`en` English, `vi` Tiếng Việt, `zh` and `zh-Hans` 中文), else the browser's
|
|
242
|
+
(`Intl.DisplayNames`, first letter capitalised), else the code. A link whose host is the host of
|
|
243
|
+
`<link rel="canonical">` (else this page's host) keeps only its path, search and hash, so a preview deployment stays
|
|
244
|
+
on itself. The button's `.gw-lang__short` gets the current language's short code (`EN`, `VI`, `中`, else its base code
|
|
245
|
+
in capitals) and its `lang`.
|
|
246
|
+
- **Hidden** while the list is empty: a page with no other language (no alternates, or only its own) keeps `hidden`;
|
|
247
|
+
the script takes it away when there is one. Every `init` reads the links again, so a route change that brings other
|
|
248
|
+
alternates updates both switchers.
|
|
249
|
+
- **Words**: `data-gw-label-language` on the button (else its `aria-label`, else "Language"): the button is named
|
|
250
|
+
"Language: English" (the word, then the current language's name), the list "Language". `destroy` gives back the
|
|
251
|
+
button's own label, empties the list and hides the switcher.
|
|
252
|
+
- **Keys and pointer**: the button opens and closes the list (`aria-expanded`); ArrowDown and ArrowUp on it open the
|
|
253
|
+
list on its first or last link; in the list ArrowDown and ArrowUp move (wrapping), Home and End go to the ends;
|
|
254
|
+
Escape closes it, gives the focus back to the button and stops there (an open mobile menu around it stays open); the
|
|
255
|
+
focus leaving it, or a click outside, closes it. Closing the mobile menu closes the list inside it. No event.
|
|
256
|
+
- `check` reports a `.gw-lang` that is not `hidden`, has no `data-gw-lang`, no `.gw-lang__btn`, a button whose
|
|
257
|
+
`aria-controls` names no list inside it or without `aria-expanded`, links typed into the list, and, on a page with a
|
|
258
|
+
switcher, a `<link rel="alternate">` without `type` that lacks `hreflang` or `href` (`lang-markup`).
|
|
162
259
|
|
|
163
260
|
## Footer (0.7.0)
|
|
164
261
|
|
|
@@ -315,6 +412,39 @@ CSS only: the script does not touch a form. The page (or its server) owns valida
|
|
|
315
412
|
carry the same number (`stars-markup`), and a `.gw-rating-badge` or `.gw-testimonial` without an `<a href>`
|
|
316
413
|
(`rating-source`, a warning).
|
|
317
414
|
|
|
415
|
+
## Spotlight (0.9.0)
|
|
416
|
+
|
|
417
|
+
```html
|
|
418
|
+
<div class="gw-plan" data-gw-spot> … <a class="gw-btn" href="/signup">Start free</a> … </div>
|
|
419
|
+
<a class="gw-card gw-card--link" href="/mcp" data-gw-spot> … </a>
|
|
420
|
+
<a class="gw-mega__feature" href="/engine" data-gw-spot> … </a>
|
|
421
|
+
<a class="gw-btn" href="/signup" data-gw-spot>Start free</a> <!-- inside .gw-band--night -->
|
|
422
|
+
<a class="gw-card gw-card--link" href="/engine" data-gw-spot=".gp-card-art"> <span class="gp-card-art"></span> … </a>
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
- Markup: `data-gw-spot` on an `<a href>`, a `<button>`, or a `<div>`, `<article>` or `<li>` that holds exactly one
|
|
426
|
+
link or button. Empty, the host carries the light; a selector (`data-gw-spot=".gp-card-art"`) names the inner
|
|
427
|
+
element that carries it instead (the first match). Never a heading, as the host or as the selector's match. `check`
|
|
428
|
+
reports anything else (`spot-target`).
|
|
429
|
+
- Script behaviour (`spot`): in a browser with `@property` and `color-mix()` (else nothing is wired), each host is
|
|
430
|
+
wired once (`data-gw-ready="spot"`), and the element that carries the light gets `.gw-spot` (kept on `destroy` when
|
|
431
|
+
the markup wrote it). On a fine pointer (`(hover: hover) and (pointer: fine)`) without reduced motion, and again on
|
|
432
|
+
every change of either, the host has a `pointerenter` and a `pointermove` listener: entering writes the pointer's
|
|
433
|
+
place at once, in px from the padding box of the element that carries the light, as `--gw-spot-x` and
|
|
434
|
+
`--gw-spot-y` on it; a move asks for one animation frame, and each frame moves the place 18% of the way to the
|
|
435
|
+
pointer until it is within 0.3px (`system.json` `motion.spot.trail`, `.settle`). A touch pointer is ignored.
|
|
436
|
+
`destroy` removes the listeners, cancels the frame, takes `.gw-spot` and the two properties away. No event.
|
|
437
|
+
- CSS (`css/effects.css`, all of it under `(hover: hover) and (pointer: fine)`): the host's `--gw-spot-r` (a registered,
|
|
438
|
+
inherited length) is 0 at rest and `--gw-spot-radius` on hover, on `:focus-visible` and while a descendant has
|
|
439
|
+
`:focus-visible` (`--gw-spot-radius-action` on a `.gw-btn`); the `::before` of `.gw-spot` draws a radial gradient of
|
|
440
|
+
`--gw-accent` at that radius and place, under the words, with `border-radius: inherit`, and transitions the radius
|
|
441
|
+
(`--gw-spot-ms`, `--gw-spot-ease`). Keyboard focus and reduced motion put the light in the middle (on the
|
|
442
|
+
`::before`, so the script's place does not count); reduced motion also drops the transition. Not on a busy button
|
|
443
|
+
(its `::before` is the ring). While the host is lit (hover, `:focus-visible`, a descendant with `:focus-visible`) it
|
|
444
|
+
sets `--gw-muted: var(--gw-ink-2)`, so muted words inside it keep 4.5:1 at the light's middle; descendants take back
|
|
445
|
+
the muted colour only once the light has gone (`transition: color 0s` with the light's time as delay, in `:where()`,
|
|
446
|
+
so a part's own transition wins; none under reduced motion).
|
|
447
|
+
|
|
318
448
|
## Brand profiles (0.3.0)
|
|
319
449
|
|
|
320
450
|
- `data-gw-action` and `data-gw-radius` sit on `<html>` only, with a name from `system.json` `profiles` (`ink` or
|
package/docs/theming.md
CHANGED
|
@@ -15,8 +15,9 @@ widths. That is what makes the sites look like one family, and why a site built
|
|
|
15
15
|
still looks careful. `npx gemboss-website check` reports anything outside this set.
|
|
16
16
|
|
|
17
17
|
Working examples: `demo/theme-example.html` (the accent only), `demo/brand-profiles.html` (a GemPages-style brand:
|
|
18
|
-
the accent, all three profiles and the floating header)
|
|
19
|
-
footer for a GemPages-style brand).
|
|
18
|
+
the accent, all three profiles and the floating header), `demo/footer.html` (the footer, with a switch to the same
|
|
19
|
+
footer for a GemPages-style brand) and `demo/header.html` (the header with the drill-down phone menu and the language
|
|
20
|
+
switcher, with the same switch: the bar's buttons and the language list follow the radius and action profiles).
|
|
20
21
|
|
|
21
22
|
## 1 to 3. Accent, wash, logo
|
|
22
23
|
|
|
@@ -40,6 +41,11 @@ Why the `-light` / `-dark` names and not `--gw-accent` itself: the kit sets `--g
|
|
|
40
41
|
band. Override the eight primitives once and all of them follow. Overriding `--gw-accent` directly works only until
|
|
41
42
|
someone presses the theme toggle.
|
|
42
43
|
|
|
44
|
+
The spotlight (0.9.0, `data-gw-spot`) is the accent too: its light is `--gw-accent` mixed over what it lights, so a
|
|
45
|
+
brand's page lights in the brand's own colour, in light, in dark and in night bands, with nothing to set. The light
|
|
46
|
+
takes the corners of the element it lights, so both radius profiles hold. A brand does not choose its colour, its
|
|
47
|
+
strength or its size (they are tokens), and never a second colour.
|
|
48
|
+
|
|
43
49
|
The logo and the favicon are files, not variables: the `<img class="gw-brand__mark">` (26×26, SVG preferred, with
|
|
44
50
|
`alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
|
|
45
51
|
partials, and `<link rel="icon">` in the page head.
|
|
@@ -93,6 +99,7 @@ the panels; in `tight` that is the 16px the header was designed with.
|
|
|
93
99
|
- **Accent-coloured section bands.** A section is white, `--gw-bg-soft` or a night band; the accent only lights a
|
|
94
100
|
night band's top edge.
|
|
95
101
|
- **All-caps labels.** Labels are sentence case, muted, led by the 8px accent square.
|
|
102
|
+
- **A light on a heading, or in more than one colour.** The spotlight lights things you click, in the accent only.
|
|
96
103
|
|
|
97
104
|
These make a page louder without making it clearer, and `check` reports the last one (`uppercase-label`) and any
|
|
98
105
|
button filled from site CSS (`accent-button`).
|
package/js/website.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
/*! @gemboss/website-ui 0.
|
|
1
|
+
/*! @gemboss/website-ui 0.9.0 website.js | GENERATED by scripts/build.mjs from js/website.mjs (the source, with its comments): edit that file | spec: docs/markup-contract.md | load with defer */
|
|
2
2
|
(function () {
|
|
3
3
|
'use strict';
|
|
4
4
|
if (window.gwWebsite) return;
|
|
5
5
|
|
|
6
|
-
var version = '0.
|
|
6
|
+
var version = '0.9.0';
|
|
7
7
|
var api = { version: version, init: init, destroy: destroy };
|
|
8
8
|
var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
|
|
9
|
+
var still, lights;
|
|
9
10
|
var READY = 'data-gw-ready';
|
|
10
11
|
|
|
11
12
|
function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
|
|
@@ -93,24 +94,136 @@ function mobile(ctx) {
|
|
|
93
94
|
var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
|
|
94
95
|
if (!menu) return;
|
|
95
96
|
var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
|
|
97
|
+
var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
|
|
98
|
+
var own = groups.map(function (g) { return g.open; }), cur = null;
|
|
99
|
+
function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
|
|
96
100
|
var r = {
|
|
97
101
|
open: function () { return !menu.hidden; },
|
|
98
102
|
set: function (o, focus) {
|
|
103
|
+
if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
|
|
99
104
|
menu.hidden = !o;
|
|
100
105
|
btn.setAttribute('aria-expanded', String(o));
|
|
101
106
|
btn.setAttribute('aria-label', label[+o]);
|
|
102
107
|
root.classList.toggle('is-menu-open', o);
|
|
108
|
+
if (!o) {
|
|
109
|
+
r.go(null);
|
|
110
|
+
records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
|
|
111
|
+
}
|
|
103
112
|
if (focus) btn.focus();
|
|
113
|
+
emit(menu, 'gw:menu', { open: o, level: '' });
|
|
104
114
|
},
|
|
105
|
-
|
|
115
|
+
go: function (g, focus) {
|
|
116
|
+
var from = cur;
|
|
117
|
+
if (g === cur) return;
|
|
118
|
+
cur = g;
|
|
119
|
+
groups.forEach(function (x) { x.open = x === g; });
|
|
120
|
+
menu.classList.toggle('is-drilled', !!g);
|
|
121
|
+
menu.classList.toggle('is-back', !g && r.open());
|
|
122
|
+
menu.scrollTop = 0;
|
|
123
|
+
if (focus) (g ? head(g) : from.querySelector('summary')).focus();
|
|
124
|
+
if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
|
|
125
|
+
},
|
|
126
|
+
trap: function (e) {
|
|
127
|
+
var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
|
|
128
|
+
var a = doc.activeElement, last = stops[stops.length - 1];
|
|
129
|
+
if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
|
|
130
|
+
},
|
|
131
|
+
stop: function () {
|
|
132
|
+
if (r.open()) r.set(false);
|
|
133
|
+
if (!groups.length) return;
|
|
134
|
+
menu.classList.remove('is-drill');
|
|
135
|
+
menu.style.removeProperty('--gw-menu-top');
|
|
136
|
+
groups.forEach(function (g, i) { g.open = own[i]; });
|
|
137
|
+
}
|
|
106
138
|
};
|
|
107
139
|
on(btn, 'click', function () { r.set(!r.open()); });
|
|
108
140
|
on(menu, 'click', function (e) { if (link(e)) r.set(false); });
|
|
141
|
+
groups.forEach(function (g) {
|
|
142
|
+
var back = g.querySelector('.gw-mobile-menu__back');
|
|
143
|
+
on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
|
|
144
|
+
if (back) on(back, 'click', function () { r.go(null, true); });
|
|
145
|
+
g.open = false;
|
|
146
|
+
});
|
|
147
|
+
if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
|
|
109
148
|
return r;
|
|
110
149
|
});
|
|
111
150
|
}
|
|
112
151
|
function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
|
|
113
152
|
|
|
153
|
+
var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
|
|
154
|
+
function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
|
|
155
|
+
function speak(x) {
|
|
156
|
+
var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
|
|
157
|
+
try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
|
|
158
|
+
n = n || x.code;
|
|
159
|
+
return n.charAt(0).toUpperCase() + n.slice(1);
|
|
160
|
+
}
|
|
161
|
+
function alternates() {
|
|
162
|
+
var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
|
|
163
|
+
try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
|
|
164
|
+
all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
|
|
165
|
+
var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
|
|
166
|
+
try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
|
|
167
|
+
if (k === 'x-default' || seen[k]) return;
|
|
168
|
+
seen[k] = 1;
|
|
169
|
+
list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
|
|
170
|
+
});
|
|
171
|
+
if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
|
|
172
|
+
return list;
|
|
173
|
+
}
|
|
174
|
+
function lang(ctx) {
|
|
175
|
+
each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
|
|
176
|
+
var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
|
|
177
|
+
if (!list) return;
|
|
178
|
+
var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
|
|
179
|
+
function links() { return all('a[href]', list); }
|
|
180
|
+
var r = {
|
|
181
|
+
open: function () { return !list.hidden; },
|
|
182
|
+
set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
|
|
183
|
+
fill: function () {
|
|
184
|
+
var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
|
|
185
|
+
list.textContent = '';
|
|
186
|
+
alts.forEach(function (x) {
|
|
187
|
+
if (x === cur) return;
|
|
188
|
+
var li = doc.createElement('li'), a = doc.createElement('a');
|
|
189
|
+
a.className = 'gw-lang__link';
|
|
190
|
+
a.setAttribute('href', x.href);
|
|
191
|
+
a.setAttribute('hreflang', x.code);
|
|
192
|
+
a.setAttribute('lang', x.code);
|
|
193
|
+
a.textContent = speak(x);
|
|
194
|
+
li.appendChild(a);
|
|
195
|
+
list.appendChild(li);
|
|
196
|
+
});
|
|
197
|
+
box.hidden = !links().length;
|
|
198
|
+
if (box.hidden) r.set(false);
|
|
199
|
+
if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
|
|
200
|
+
btn.setAttribute('aria-label', word + ': ' + speak(cur));
|
|
201
|
+
list.setAttribute('aria-label', word);
|
|
202
|
+
},
|
|
203
|
+
stop: function () {
|
|
204
|
+
r.set(false);
|
|
205
|
+
list.textContent = '';
|
|
206
|
+
box.hidden = true;
|
|
207
|
+
if (short) { short.textContent = ''; short.removeAttribute('lang'); }
|
|
208
|
+
if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
on(btn, 'click', function () { r.set(!r.open()); });
|
|
212
|
+
on(box, 'keydown', function (e) {
|
|
213
|
+
if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
|
|
214
|
+
var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
|
|
215
|
+
var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
|
|
216
|
+
if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
r.set(true);
|
|
219
|
+
l[k % n].focus();
|
|
220
|
+
});
|
|
221
|
+
on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
|
|
222
|
+
return r;
|
|
223
|
+
});
|
|
224
|
+
records('lang', function (r) { r.fill(); });
|
|
225
|
+
}
|
|
226
|
+
|
|
114
227
|
function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
|
|
115
228
|
function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
|
|
116
229
|
function theme(ctx) {
|
|
@@ -265,6 +378,51 @@ function folds(ctx) {
|
|
|
265
378
|
});
|
|
266
379
|
}
|
|
267
380
|
|
|
381
|
+
var TRAIL = 0.18, SETTLE = 0.3, HEADING = /^H[1-6]$/;
|
|
382
|
+
function spot(ctx) {
|
|
383
|
+
if (lights) each(ctx, '[data-gw-spot]', 'spot', function (el) {
|
|
384
|
+
var sel = attr(el, 'data-gw-spot', ''), t = el;
|
|
385
|
+
if (sel) { try { t = el.querySelector(sel); } catch (e) { t = null; } }
|
|
386
|
+
if (!t || HEADING.test(t.tagName) || HEADING.test(el.tagName)) return;
|
|
387
|
+
var had = t.classList.contains('gw-spot'), x = 0, y = 0, px = 0, py = 0, raf = 0, heard = false;
|
|
388
|
+
function put(nx, ny) {
|
|
389
|
+
x = nx; y = ny;
|
|
390
|
+
t.style.setProperty('--gw-spot-x', x.toFixed(1) + 'px');
|
|
391
|
+
t.style.setProperty('--gw-spot-y', y.toFixed(1) + 'px');
|
|
392
|
+
}
|
|
393
|
+
function note(e) { if (e.pointerType === 'touch') return false; px = e.clientX; py = e.clientY; return true; }
|
|
394
|
+
function aim() { var b = t.getBoundingClientRect(); return [px - b.left - (t.clientLeft || 0), py - b.top - (t.clientTop || 0)]; }
|
|
395
|
+
function step() {
|
|
396
|
+
var a = aim(), nx = x + (a[0] - x) * TRAIL, ny = y + (a[1] - y) * TRAIL, done = Math.abs(a[0] - nx) < SETTLE && Math.abs(a[1] - ny) < SETTLE;
|
|
397
|
+
if (done) put(a[0], a[1]); else put(nx, ny);
|
|
398
|
+
raf = done ? 0 : requestAnimationFrame(step);
|
|
399
|
+
}
|
|
400
|
+
function halt() { cancelAnimationFrame(raf); raf = 0; }
|
|
401
|
+
function enter(e) { if (note(e)) { halt(); put.apply(null, aim()); } }
|
|
402
|
+
function move(e) { if (note(e) && !raf) raf = requestAnimationFrame(step); }
|
|
403
|
+
function listen(want) {
|
|
404
|
+
if (want === heard) return;
|
|
405
|
+
heard = want;
|
|
406
|
+
[['pointerenter', enter], ['pointermove', move]].forEach(function (l) {
|
|
407
|
+
if (want) el.addEventListener(l[0], l[1]); else el.removeEventListener(l[0], l[1]);
|
|
408
|
+
});
|
|
409
|
+
if (!want) halt();
|
|
410
|
+
}
|
|
411
|
+
var r = {
|
|
412
|
+
fit: function () { listen(fine.matches && !still.matches); },
|
|
413
|
+
stop: function () {
|
|
414
|
+
listen(false);
|
|
415
|
+
if (!had) t.classList.remove('gw-spot');
|
|
416
|
+
t.style.removeProperty('--gw-spot-x');
|
|
417
|
+
t.style.removeProperty('--gw-spot-y');
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
t.classList.add('gw-spot');
|
|
421
|
+
r.fit();
|
|
422
|
+
return r;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
268
426
|
function install() {
|
|
269
427
|
doc = document; root = doc.documentElement; mine = new WeakSet();
|
|
270
428
|
fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
|
|
@@ -274,8 +432,14 @@ function install() {
|
|
|
274
432
|
on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
|
|
275
433
|
on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
|
|
276
434
|
on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
|
|
435
|
+
on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
|
|
436
|
+
on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
|
|
277
437
|
watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
|
|
278
438
|
watch(mid, function () { records('fold', function (r) { r.fit(); }); });
|
|
439
|
+
still = mq('(prefers-reduced-motion: reduce)');
|
|
440
|
+
var css = window.CSS;
|
|
441
|
+
lights = !!(css && css.registerProperty && css.supports && css.supports('color', 'color-mix(in srgb, red 50%, blue)'));
|
|
442
|
+
[fine, still].forEach(function (m) { watch(m, function () { records('spot', function (r) { r.fit(); }); }); });
|
|
279
443
|
on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
|
|
280
444
|
try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
|
|
281
445
|
if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
|
|
@@ -300,7 +464,7 @@ function init(ctx) {
|
|
|
300
464
|
window.gwWebsite = api;
|
|
301
465
|
if (!doc) run([install]);
|
|
302
466
|
live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
|
|
303
|
-
run([mega, mobile, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
|
|
467
|
+
run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds, spot], ctx && ctx.querySelectorAll ? ctx : doc);
|
|
304
468
|
}
|
|
305
469
|
|
|
306
470
|
/** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
|