@gemboss/website-ui 0.7.0 → 0.8.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.
@@ -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), and the footer's folds (0.7.0). It comes in two entries built from one source
31
+ prose tables (0.5.0), the footer's folds (0.7.0), and the mobile menu's drill-down and the language switcher (0.8.0).
32
+ 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.7.0'`. The classic script exposes the three as `window.gwWebsite`.
47
- - **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `theme`, `reveal`, `header`,
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.8.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`). 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 &amp; 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
 
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) and `demo/footer.html` (the footer, with a switch to the same
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
 
package/js/website.js CHANGED
@@ -1,9 +1,9 @@
1
- /*! @gemboss/website-ui 0.7.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 */
1
+ /*! @gemboss/website-ui 0.8.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.7.0';
6
+ var version = '0.8.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
9
  var READY = 'data-gw-ready';
@@ -93,24 +93,136 @@ function mobile(ctx) {
93
93
  var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
94
94
  if (!menu) return;
95
95
  var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
96
+ var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
97
+ var own = groups.map(function (g) { return g.open; }), cur = null;
98
+ function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
96
99
  var r = {
97
100
  open: function () { return !menu.hidden; },
98
101
  set: function (o, focus) {
102
+ if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
99
103
  menu.hidden = !o;
100
104
  btn.setAttribute('aria-expanded', String(o));
101
105
  btn.setAttribute('aria-label', label[+o]);
102
106
  root.classList.toggle('is-menu-open', o);
107
+ if (!o) {
108
+ r.go(null);
109
+ records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
110
+ }
103
111
  if (focus) btn.focus();
112
+ emit(menu, 'gw:menu', { open: o, level: '' });
104
113
  },
105
- stop: function () { if (r.open()) r.set(false); }
114
+ go: function (g, focus) {
115
+ var from = cur;
116
+ if (g === cur) return;
117
+ cur = g;
118
+ groups.forEach(function (x) { x.open = x === g; });
119
+ menu.classList.toggle('is-drilled', !!g);
120
+ menu.classList.toggle('is-back', !g && r.open());
121
+ menu.scrollTop = 0;
122
+ if (focus) (g ? head(g) : from.querySelector('summary')).focus();
123
+ if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
124
+ },
125
+ trap: function (e) {
126
+ var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
127
+ var a = doc.activeElement, last = stops[stops.length - 1];
128
+ if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
129
+ },
130
+ stop: function () {
131
+ if (r.open()) r.set(false);
132
+ if (!groups.length) return;
133
+ menu.classList.remove('is-drill');
134
+ menu.style.removeProperty('--gw-menu-top');
135
+ groups.forEach(function (g, i) { g.open = own[i]; });
136
+ }
106
137
  };
107
138
  on(btn, 'click', function () { r.set(!r.open()); });
108
139
  on(menu, 'click', function (e) { if (link(e)) r.set(false); });
140
+ groups.forEach(function (g) {
141
+ var back = g.querySelector('.gw-mobile-menu__back');
142
+ on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
143
+ if (back) on(back, 'click', function () { r.go(null, true); });
144
+ g.open = false;
145
+ });
146
+ if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
109
147
  return r;
110
148
  });
111
149
  }
112
150
  function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
113
151
 
152
+ var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
153
+ function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
154
+ function speak(x) {
155
+ var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
156
+ try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
157
+ n = n || x.code;
158
+ return n.charAt(0).toUpperCase() + n.slice(1);
159
+ }
160
+ function alternates() {
161
+ var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
162
+ try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
163
+ all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
164
+ var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
165
+ try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
166
+ if (k === 'x-default' || seen[k]) return;
167
+ seen[k] = 1;
168
+ list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
169
+ });
170
+ if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
171
+ return list;
172
+ }
173
+ function lang(ctx) {
174
+ each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
175
+ var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
176
+ if (!list) return;
177
+ var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
178
+ function links() { return all('a[href]', list); }
179
+ var r = {
180
+ open: function () { return !list.hidden; },
181
+ set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
182
+ fill: function () {
183
+ var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
184
+ list.textContent = '';
185
+ alts.forEach(function (x) {
186
+ if (x === cur) return;
187
+ var li = doc.createElement('li'), a = doc.createElement('a');
188
+ a.className = 'gw-lang__link';
189
+ a.setAttribute('href', x.href);
190
+ a.setAttribute('hreflang', x.code);
191
+ a.setAttribute('lang', x.code);
192
+ a.textContent = speak(x);
193
+ li.appendChild(a);
194
+ list.appendChild(li);
195
+ });
196
+ box.hidden = !links().length;
197
+ if (box.hidden) r.set(false);
198
+ if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
199
+ btn.setAttribute('aria-label', word + ': ' + speak(cur));
200
+ list.setAttribute('aria-label', word);
201
+ },
202
+ stop: function () {
203
+ r.set(false);
204
+ list.textContent = '';
205
+ box.hidden = true;
206
+ if (short) { short.textContent = ''; short.removeAttribute('lang'); }
207
+ if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
208
+ }
209
+ };
210
+ on(btn, 'click', function () { r.set(!r.open()); });
211
+ on(box, 'keydown', function (e) {
212
+ if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
213
+ var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
214
+ var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
215
+ if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
216
+ e.preventDefault();
217
+ r.set(true);
218
+ l[k % n].focus();
219
+ });
220
+ on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
221
+ return r;
222
+ });
223
+ records('lang', function (r) { r.fill(); });
224
+ }
225
+
114
226
  function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
115
227
  function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
116
228
  function theme(ctx) {
@@ -274,6 +386,8 @@ function install() {
274
386
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
275
387
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
276
388
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
389
+ on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
390
+ on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
277
391
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
278
392
  watch(mid, function () { records('fold', function (r) { r.fit(); }); });
279
393
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
@@ -300,7 +414,7 @@ function init(ctx) {
300
414
  window.gwWebsite = api;
301
415
  if (!doc) run([install]);
302
416
  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);
417
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
304
418
  }
305
419
 
306
420
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
package/js/website.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // the export line and the // comments, plus auto-init): edit this file, then run the build. Keep comments on lines of
7
7
  // their own, so the build can leave them out.
8
8
 
9
- var version = '0.7.0';
9
+ var version = '0.8.0';
10
10
  var api = { version: version, init: init, destroy: destroy };
11
11
  // Page state. Set up by the first init(), never at import: a server can import this file.
12
12
  var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
@@ -101,30 +101,168 @@ function mega(ctx) {
101
101
  }
102
102
 
103
103
  // Mobile menu. Labels: data-gw-label-open/-close, else the markup's aria-label, else English. A link inside closes it
104
- // (same-page anchors do not reload). Taken back while open, it closes first: it must not keep the page locked.
104
+ // (same-page anchors do not reload). Taken back while open, it closes first: it must not keep the page locked. Opening
105
+ // and closing fire gw:menu on the menu ({ open, level: '' }); closing also closes a language list inside it.
106
+ //
107
+ // Drill-down (0.8.0, gap #41): a menu with data-gw-menu="drill". Each group in it (a <details class="gw-mobile-menu__group">
108
+ // with its <summary> and a .gw-mobile-menu__title) is a level. A press on the summary shows that level alone
109
+ // (.is-drilled on the menu, the group open, the menu scrolled to its top) and puts the focus on the level's title; its
110
+ // .gw-mobile-menu__back shows the top level again (.is-back, for the CSS's slide) and puts the focus back on the summary.
111
+ // Each move fires gw:menu ({ open: true, level }: the title's words, '' for the top level); closing the menu goes back
112
+ // to the top level without a word of its own. While it is open, Tab and Shift+Tab go round the toggle and the menu (the
113
+ // focus trap), and the menu starts where the header ends (--gw-menu-top, measured as it opens), so an announcement bar
114
+ // above the header stays where it is. .is-drill on the menu says it is wired: without the script (or before init) the
115
+ // groups are the 0.7.0 <details> that open in place. destroy gives back the groups' own open state.
105
116
  function mobile(ctx) {
106
117
  each(ctx, '[data-gw-menu-toggle]', 'menu', function (btn, on) {
107
118
  var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
108
119
  if (!menu) return;
109
120
  var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
121
+ var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
122
+ var own = groups.map(function (g) { return g.open; }), cur = null;
123
+ function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
110
124
  var r = {
111
125
  open: function () { return !menu.hidden; },
112
126
  set: function (o, focus) {
127
+ if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
113
128
  menu.hidden = !o;
114
129
  btn.setAttribute('aria-expanded', String(o));
115
130
  btn.setAttribute('aria-label', label[+o]);
116
131
  root.classList.toggle('is-menu-open', o);
132
+ if (!o) {
133
+ r.go(null);
134
+ records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
135
+ }
117
136
  if (focus) btn.focus();
137
+ emit(menu, 'gw:menu', { open: o, level: '' });
138
+ },
139
+ // the level in view: a group, or null for the top level
140
+ go: function (g, focus) {
141
+ var from = cur;
142
+ if (g === cur) return;
143
+ cur = g;
144
+ groups.forEach(function (x) { x.open = x === g; });
145
+ menu.classList.toggle('is-drilled', !!g);
146
+ menu.classList.toggle('is-back', !g && r.open());
147
+ menu.scrollTop = 0;
148
+ if (focus) (g ? head(g) : from.querySelector('summary')).focus();
149
+ if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
118
150
  },
119
- stop: function () { if (r.open()) r.set(false); }
151
+ // Tab on the last stop goes to the toggle, Shift+Tab on the toggle to the last stop; focus found outside comes in
152
+ trap: function (e) {
153
+ var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
154
+ var a = doc.activeElement, last = stops[stops.length - 1];
155
+ if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
156
+ },
157
+ stop: function () {
158
+ if (r.open()) r.set(false);
159
+ if (!groups.length) return;
160
+ menu.classList.remove('is-drill');
161
+ menu.style.removeProperty('--gw-menu-top');
162
+ groups.forEach(function (g, i) { g.open = own[i]; });
163
+ }
120
164
  };
121
165
  on(btn, 'click', function () { r.set(!r.open()); });
122
166
  on(menu, 'click', function (e) { if (link(e)) r.set(false); });
167
+ groups.forEach(function (g) {
168
+ var back = g.querySelector('.gw-mobile-menu__back');
169
+ on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
170
+ if (back) on(back, 'click', function () { r.go(null, true); });
171
+ g.open = false;
172
+ });
173
+ if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
123
174
  return r;
124
175
  });
125
176
  }
126
177
  function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
127
178
 
179
+ // Language switcher (0.8.0, gap #29): a .gw-lang with data-gw-lang, its button .gw-lang__btn and the list its
180
+ // aria-controls names. The languages are the page's own <link rel="alternate" hreflang href> (not x-default): the links
181
+ // search engines read, so the switcher offers exactly the translations they are told about. The one whose hreflang is
182
+ // <html lang> (else the first with the same base language: zh for zh-Hans) is the current one: the button shows its
183
+ // short code. Each other one is a link in the list (href, hreflang, lang), named in its own language: the <link>'s
184
+ // title, else the kit's name (en, vi, zh), else the browser's (Intl.DisplayNames), else its code. A link to the host of
185
+ // <link rel="canonical"> (else to this host) keeps only its path, so a preview deployment stays on itself. With no other
186
+ // language the switcher keeps `hidden`. Every init reads the links again: a route change brings the new page's.
187
+ // The button opens and closes the list. ArrowDown / ArrowUp on it open the list on its first / last link; in the list
188
+ // they move, wrapping, and Home / End go to the ends. Escape closes it and gives the focus back to the button (and goes
189
+ // no further: an open mobile menu around it stays open); the focus leaving it, or a click outside, closes it.
190
+ // Labels: data-gw-label-language on the button, else its aria-label, else "Language": the button is named
191
+ // "<label>: <the current language>", the list "<label>". destroy empties the list and hides the switcher again.
192
+ var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
193
+ function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
194
+ function speak(x) {
195
+ var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
196
+ try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
197
+ n = n || x.code;
198
+ return n.charAt(0).toUpperCase() + n.slice(1);
199
+ }
200
+ function alternates() {
201
+ var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
202
+ try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
203
+ all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
204
+ var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
205
+ try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
206
+ if (k === 'x-default' || seen[k]) return;
207
+ seen[k] = 1;
208
+ list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
209
+ });
210
+ if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
211
+ return list;
212
+ }
213
+ function lang(ctx) {
214
+ each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
215
+ var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
216
+ if (!list) return;
217
+ var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
218
+ function links() { return all('a[href]', list); }
219
+ var r = {
220
+ open: function () { return !list.hidden; },
221
+ set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
222
+ fill: function () {
223
+ var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
224
+ list.textContent = '';
225
+ alts.forEach(function (x) {
226
+ if (x === cur) return;
227
+ var li = doc.createElement('li'), a = doc.createElement('a');
228
+ a.className = 'gw-lang__link';
229
+ a.setAttribute('href', x.href);
230
+ a.setAttribute('hreflang', x.code);
231
+ a.setAttribute('lang', x.code);
232
+ a.textContent = speak(x);
233
+ li.appendChild(a);
234
+ list.appendChild(li);
235
+ });
236
+ box.hidden = !links().length;
237
+ if (box.hidden) r.set(false);
238
+ if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
239
+ btn.setAttribute('aria-label', word + ': ' + speak(cur));
240
+ list.setAttribute('aria-label', word);
241
+ },
242
+ stop: function () {
243
+ r.set(false);
244
+ list.textContent = '';
245
+ box.hidden = true;
246
+ if (short) { short.textContent = ''; short.removeAttribute('lang'); }
247
+ if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
248
+ }
249
+ };
250
+ on(btn, 'click', function () { r.set(!r.open()); });
251
+ on(box, 'keydown', function (e) {
252
+ if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
253
+ var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
254
+ var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
255
+ if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
256
+ e.preventDefault();
257
+ r.set(true);
258
+ l[k % n].focus();
259
+ });
260
+ on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
261
+ return r;
262
+ });
263
+ records('lang', function (r) { r.fill(); });
264
+ }
265
+
128
266
  // Theme toggle. Labels: data-gw-label-dark/-light, else the markup's aria-label, else English. A press fires gw:theme
129
267
  // on <html>; so does a change of the system's scheme while the page follows it.
130
268
  function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
@@ -319,6 +457,9 @@ function install() {
319
457
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
320
458
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
321
459
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
460
+ // a drill-down menu that is open keeps the focus (its trap); a click outside a language list closes it
461
+ on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
462
+ on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
322
463
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
323
464
  watch(mid, function () { records('fold', function (r) { r.fit(); }); });
324
465
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
@@ -351,7 +492,7 @@ function init(ctx) {
351
492
  if (!doc) run([install]);
352
493
  // an element removed from the page since the last init is let go
353
494
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
354
- run([mega, mobile, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
495
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
355
496
  }
356
497
 
357
498
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The GemBoss website kit: tokens, header, footer, sections and the rules (AGENTS.md) that GemCommerce public websites share, plus a CLI that checks a site, reports kit gaps and upgrades.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -28,6 +28,10 @@ the kit and its rules are how the page ends up looking designed. Compose from th
28
28
  - The footer is `KIT/templates/footer.html` (the footer of gemboss.ai), filled once in the site's shared partial:
29
29
  fill its rows (columns, family rows, words), delete the optional parts you do not use (Ask AI, newsletter, family
30
30
  rows, theme toggle, wordmark), never invent a footer part. Columns fold on phones by themselves (`<details>`).
31
+ - The header is `KIT/templates/header.html`, filled once in the site's shared partial. A site with deep sections
32
+ uses its drill-down mobile menu (`data-gw-menu="drill"`, a head with Back and the title in each group); a site in
33
+ more than one language keeps its language switcher (`.gw-lang`, `hidden`, empty) and gives every page its
34
+ `<link rel="alternate" hreflang>`: the switcher lists those, and stays hidden on a page in one language.
31
35
  - Changed page: keep its kit classes; change words, order and choice of sections.
32
36
  - Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
33
37
  scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.