@jelinek/ui 0.3.0 → 0.4.1
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 +33 -10
- package/dist/components/PageHero.svelte +37 -3
- package/dist/components/PageHero.svelte.d.ts +6 -0
- package/dist/components/SiteFooter.svelte +81 -1
- package/dist/components/SiteFooter.svelte.d.ts +19 -0
- package/dist/components/SiteHeader.svelte +340 -28
- package/dist/components/SiteHeader.svelte.d.ts +16 -0
- package/dist/components/Tab.svelte +9 -2
- package/dist/components/deer-mark-path.d.ts +1 -1
- package/dist/components/deer-mark-path.js +5 -1
- package/dist/components/nav.d.ts +21 -0
- package/dist/components/nav.js +3 -2
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Packages for a package that is no longer published there:
|
|
|
26
26
|
```json
|
|
27
27
|
{
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@jelinek/ui": "^0.
|
|
29
|
+
"@jelinek/ui": "^0.4.0"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
```
|
|
@@ -465,15 +465,38 @@ The `publish` job is separate and gated on a tag matching `ui-v*` — it
|
|
|
465
465
|
does not run on ordinary merges to `master`, only when someone pushes a
|
|
466
466
|
`ui-v*` tag, and it also depends on `verify` passing first. It publishes
|
|
467
467
|
to **npmjs.org with `--access public`**, authenticating with the
|
|
468
|
-
`NPM_TOKEN` repo secret (a granular
|
|
469
|
-
|
|
470
|
-
`JELINEK-nabytek-a-matrace`).
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
468
|
+
`NPM_TOKEN` repo secret (a granular token for the `jelinek` npm org — a
|
|
469
|
+
different namespace from the GitHub org, which is named
|
|
470
|
+
`JELINEK-nabytek-a-matrace`). No provenance (`--provenance`) is
|
|
471
|
+
generated: npm rejects it for builds from a private repository.
|
|
472
|
+
|
|
473
|
+
**That job does not currently work, and 0.3.0 was released by hand.** npm
|
|
474
|
+
requires a second factor for every publish, and a granular token only
|
|
475
|
+
satisfies that if "Bypass two-factor authentication (2FA)" was ticked
|
|
476
|
+
*when the token was created* — the setting cannot be added to an existing
|
|
477
|
+
token, and npm is in the middle of restricting bypass tokens outright
|
|
478
|
+
(account changes Aug 2026, direct publishing Jan 2027). Without it the
|
|
479
|
+
registry answers a `PUT` with a misleading `E404 Not Found` in CI, and a
|
|
480
|
+
clearer `E403 … Two-factor authentication or granular access token with
|
|
481
|
+
bypass 2fa enabled is required` locally.
|
|
482
|
+
|
|
483
|
+
So the working release procedure is manual, from a machine that can reach
|
|
484
|
+
a browser:
|
|
485
|
+
|
|
486
|
+
```
|
|
487
|
+
cd svelte && npm publish --access public --auth-type=web
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
That prints a `npmjs.com/auth/cli/…` URL; approving it there with a
|
|
491
|
+
passkey (or an authenticator app) completes the publish. `--otp=<code>`
|
|
492
|
+
works too, but only with an authenticator app — a passkey produces no
|
|
493
|
+
code. Push the `ui-v*` tag anyway: `verify` still gates the release, and
|
|
494
|
+
the tag is what records which commit a version came from.
|
|
495
|
+
|
|
496
|
+
Fixing CI means either a bypass-enabled token while npm still issues
|
|
497
|
+
them, or trusted publishing via GitHub Actions OIDC — the latter is
|
|
498
|
+
untested here and may be unavailable for the same reason provenance is,
|
|
499
|
+
namely that this repo is private.
|
|
477
500
|
|
|
478
501
|
## Known design debt
|
|
479
502
|
|
|
@@ -53,6 +53,12 @@
|
|
|
53
53
|
* real `.page-hero` in the guide has this; kept optional since
|
|
54
54
|
* jelinek.css itself allows a bare eyebrow+heading (brand/znacka.html). */
|
|
55
55
|
children?: Snippet;
|
|
56
|
+
/** Full-bleed background medium — a photo or video behind the hero.
|
|
57
|
+
* Render a bare `<img>` or `<video>`; the wrapper stretches it across
|
|
58
|
+
* the WHOLE band (edge to edge of the window, like the `--hero-bg`
|
|
59
|
+
* colour) and crops it with object-fit: cover. Mirrors the guide's
|
|
60
|
+
* `.page-hero__media`. The text content is lifted above it. */
|
|
61
|
+
media?: Snippet;
|
|
56
62
|
class?: string;
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -64,6 +70,7 @@
|
|
|
64
70
|
eyebrow,
|
|
65
71
|
heading,
|
|
66
72
|
children,
|
|
73
|
+
media,
|
|
67
74
|
class: className,
|
|
68
75
|
...rest
|
|
69
76
|
}: Props & HTMLAttributes<HTMLElement> = $props();
|
|
@@ -92,10 +99,28 @@
|
|
|
92
99
|
// padding-bottom is the passthrough token --space-section (token-map.js
|
|
93
100
|
// keeps space-* off the Tailwind theme on purpose — see its own comment —
|
|
94
101
|
// so this stays an arbitrary var() reference, not a spacing utility).
|
|
102
|
+
// Task 44 — THE BAND'S SURFACE IS ONE VARIABLE. The colour used to be
|
|
103
|
+
// written twice (background + the 100vmax shadow), so recolouring the band
|
|
104
|
+
// took two edits that could disagree — the reader proved it in dev tools:
|
|
105
|
+
// changing `background` alone painted only the container-width rectangle
|
|
106
|
+
// red while the bleed stayed grey. Both now read --hero-bg (default: the
|
|
107
|
+
// same --color-surface-gray as before, so it still flips in dark mode);
|
|
108
|
+
// one `[--hero-bg:...]` override — or `style="--hero-bg: …"` — recolours
|
|
109
|
+
// the whole band. `relative` anchors the `media` layer below.
|
|
95
110
|
const OUTER =
|
|
96
|
-
'bg-surface-gray
|
|
111
|
+
'relative [--hero-bg:var(--color-surface-gray)] bg-[var(--hero-bg)] ' +
|
|
112
|
+
'shadow-[0_0_0_100vmax_var(--hero-bg)] ' +
|
|
97
113
|
'[clip-path:inset(0_-100vmax)] pb-[var(--space-section)]';
|
|
98
114
|
|
|
115
|
+
// The guide's `.page-hero__media`: a photo/video stretched across the whole
|
|
116
|
+
// full-bleed band. Centred 100vw wide (translateX — measured: adds NO
|
|
117
|
+
// horizontal scrollbar), clipped by the section's own clip-path. The `*:`
|
|
118
|
+
// variant sizes whatever single element the snippet renders (img/video) to
|
|
119
|
+
// cover the layer.
|
|
120
|
+
const MEDIA =
|
|
121
|
+
'pointer-events-none absolute inset-y-0 left-1/2 w-screen -translate-x-1/2 select-none ' +
|
|
122
|
+
'*:h-full *:w-full *:object-cover';
|
|
123
|
+
|
|
99
124
|
// jelinek.css:339 (.container) — jelinek.css:672 gives `.page-hero`
|
|
100
125
|
// itself explicit ZERO horizontal padding, relying ENTIRELY on
|
|
101
126
|
// `.page-hero` always being a direct child of `<main class="container">`
|
|
@@ -302,10 +327,19 @@
|
|
|
302
327
|
{/snippet}
|
|
303
328
|
|
|
304
329
|
<section {...rest} class={cn(OUTER, paddingTop, className)}>
|
|
330
|
+
{#if media}
|
|
331
|
+
<!-- aria-hidden: the medium is decoration behind the text, same as the
|
|
332
|
+
band's colour. Content is lifted above it via `relative` below. -->
|
|
333
|
+
<div class={MEDIA} aria-hidden="true">{@render media()}</div>
|
|
334
|
+
{/if}
|
|
305
335
|
{#if isContained}
|
|
306
|
-
{
|
|
336
|
+
{#if media}
|
|
337
|
+
<div class="relative">{@render content()}</div>
|
|
338
|
+
{:else}
|
|
339
|
+
{@render content()}
|
|
340
|
+
{/if}
|
|
307
341
|
{:else}
|
|
308
|
-
<div class={INNER}>
|
|
342
|
+
<div class={cn(INNER, media && 'relative')}>
|
|
309
343
|
{@render content()}
|
|
310
344
|
</div>
|
|
311
345
|
{/if}
|
|
@@ -47,6 +47,12 @@ interface Props {
|
|
|
47
47
|
* real `.page-hero` in the guide has this; kept optional since
|
|
48
48
|
* jelinek.css itself allows a bare eyebrow+heading (brand/znacka.html). */
|
|
49
49
|
children?: Snippet;
|
|
50
|
+
/** Full-bleed background medium — a photo or video behind the hero.
|
|
51
|
+
* Render a bare `<img>` or `<video>`; the wrapper stretches it across
|
|
52
|
+
* the WHOLE band (edge to edge of the window, like the `--hero-bg`
|
|
53
|
+
* colour) and crops it with object-fit: cover. Mirrors the guide's
|
|
54
|
+
* `.page-hero__media`. The text content is lifted above it. */
|
|
55
|
+
media?: Snippet;
|
|
50
56
|
class?: string;
|
|
51
57
|
}
|
|
52
58
|
type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
2
3
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
4
|
import { cn } from '../utils/cn.js';
|
|
4
5
|
|
|
@@ -8,6 +9,24 @@
|
|
|
8
9
|
slogan?: string;
|
|
9
10
|
meta?: string;
|
|
10
11
|
stamp?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Consumer content rendered inside the footer's black chrome, ABOVE the
|
|
14
|
+
* brand row (an e-shop's link columns, a newsletter box). The guide's
|
|
15
|
+
* own footer is the brand row alone, so this stays unset there and the
|
|
16
|
+
* markup is unchanged; an app footer is usually more than one strip,
|
|
17
|
+
* and without this slot it would have to stack its own black block on
|
|
18
|
+
* top of this one — two rounded-top black boxes with a seam between
|
|
19
|
+
* them. Same consumer-seam reasoning as SiteHeader's `barActions`.
|
|
20
|
+
*/
|
|
21
|
+
children?: Snippet;
|
|
22
|
+
/**
|
|
23
|
+
* Task 45 — HTML of the changelog shown in a floating <dialog> when the
|
|
24
|
+
* reader clicks the deploy stamp. The showcase passes the build-time
|
|
25
|
+
* inline of partials/changelog.html (__CHANGELOG_HTML__, see
|
|
26
|
+
* vite.config.ts) — the same file the hand-written pages SSI-include.
|
|
27
|
+
* Without it (or without `stamp`) the stamp stays a plain line.
|
|
28
|
+
*/
|
|
29
|
+
changelog?: string;
|
|
11
30
|
class?: string;
|
|
12
31
|
}
|
|
13
32
|
|
|
@@ -17,10 +36,36 @@
|
|
|
17
36
|
slogan,
|
|
18
37
|
meta,
|
|
19
38
|
stamp,
|
|
39
|
+
changelog,
|
|
40
|
+
children,
|
|
20
41
|
class: className,
|
|
21
42
|
...rest
|
|
22
43
|
}: Props & HTMLAttributes<HTMLElement> = $props();
|
|
23
44
|
|
|
45
|
+
let dialogEl = $state<HTMLDialogElement | null>(null);
|
|
46
|
+
|
|
47
|
+
// Mirrors the guide's .changelog dialog (jelinek.css) — a card over the
|
|
48
|
+
// dimmed page. The native <dialog> handles Escape and focus itself; a
|
|
49
|
+
// click landing on the dialog ELEMENT (i.e. its ::backdrop area) closes.
|
|
50
|
+
const STAMP_BUTTON =
|
|
51
|
+
'ml-auto cursor-pointer border-0 bg-transparent p-0 font-serif text-[0.85rem] text-fg-muted2 ' +
|
|
52
|
+
'transition-[color] duration-[var(--dur-base)] ease-[var(--ease)] hover:text-fg-inverse';
|
|
53
|
+
const DIALOG =
|
|
54
|
+
'm-auto w-[min(560px,calc(100vw-48px))] max-h-[min(70vh,640px)] p-0 ' +
|
|
55
|
+
'rounded-card border border-border-subtle bg-surface text-fg shadow-hover ' +
|
|
56
|
+
'backdrop:bg-black/40';
|
|
57
|
+
const DIALOG_HEAD =
|
|
58
|
+
'sticky top-0 flex items-center justify-between bg-surface px-6 pt-4';
|
|
59
|
+
const DIALOG_CLOSE =
|
|
60
|
+
'inline-flex h-11 w-11 flex-none cursor-pointer items-center justify-center ' +
|
|
61
|
+
'rounded-button border-0 bg-transparent p-0 text-fg';
|
|
62
|
+
// The changelog HTML carries the guide's own class names (.changelog__entry
|
|
63
|
+
// etc.) that mean nothing to Tailwind — style its plain tags contextually.
|
|
64
|
+
const DIALOG_BODY =
|
|
65
|
+
'px-6 pt-2 pb-6 ' +
|
|
66
|
+
'[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-[0.95rem] ' +
|
|
67
|
+
'[&_ul]:mb-2 [&_ul]:pl-[1.2em] [&_ul]:list-disc [&_li]:my-1';
|
|
68
|
+
|
|
24
69
|
// jelinek.css:759-770 (.site-footer, .site-footer__inner). `margin-top:
|
|
25
70
|
// 60px` (:760) is kept — unlike Card/Alert (Tasks 5/6), which deliberately
|
|
26
71
|
// drop their own external margins on the reasoning that a reusable
|
|
@@ -51,6 +96,15 @@
|
|
|
51
96
|
</script>
|
|
52
97
|
|
|
53
98
|
<footer {...rest} class={cn(BASE, className)}>
|
|
99
|
+
{#if children}
|
|
100
|
+
<!-- Consumer strip(s) above the brand row — see the `children` prop doc.
|
|
101
|
+
The divider separates it from the brand row the same way an app
|
|
102
|
+
would otherwise draw its own seam; the brand row below keeps its
|
|
103
|
+
guide geometry untouched. -->
|
|
104
|
+
<div class="border-b border-white/15">
|
|
105
|
+
{@render children()}
|
|
106
|
+
</div>
|
|
107
|
+
{/if}
|
|
54
108
|
<div class="mx-auto flex max-w-page flex-wrap items-center gap-6 px-6 py-10">
|
|
55
109
|
{#if logo}
|
|
56
110
|
<!-- jelinek.css:767 — 52px square, 12px radius. Neither value is on
|
|
@@ -99,7 +153,15 @@
|
|
|
99
153
|
{/if}
|
|
100
154
|
</div>
|
|
101
155
|
{/if}
|
|
102
|
-
{#if stamp}
|
|
156
|
+
{#if stamp && changelog}
|
|
157
|
+
<!-- The stamp opens the changelog dialog — same classes-worth of
|
|
158
|
+
treatment as the plain stamp below, plus the pointer/hover.
|
|
159
|
+
partials/footer.html does the identical thing with
|
|
160
|
+
#changelog-open + initChangelog() in jelinek.js. -->
|
|
161
|
+
<button type="button" class={STAMP_BUTTON} aria-haspopup="dialog" onclick={() => dialogEl?.showModal()}>
|
|
162
|
+
{stamp}
|
|
163
|
+
</button>
|
|
164
|
+
{:else if stamp}
|
|
103
165
|
<!-- jelinek.css:770 (`.site-footer .meta`): margin-left:auto pins it
|
|
104
166
|
to the far right of `.site-footer__inner`'s flex row — so this
|
|
105
167
|
has to be a sibling of the logo/slogan-wrapper above, not nested
|
|
@@ -118,4 +180,22 @@
|
|
|
118
180
|
<div class="ml-auto font-serif text-[0.85rem] text-fg-muted2">{stamp}</div>
|
|
119
181
|
{/if}
|
|
120
182
|
</div>
|
|
183
|
+
{#if stamp && changelog}
|
|
184
|
+
<dialog
|
|
185
|
+
bind:this={dialogEl}
|
|
186
|
+
class={DIALOG}
|
|
187
|
+
aria-labelledby="changelog-title"
|
|
188
|
+
onclick={(e) => e.target === dialogEl && dialogEl?.close()}
|
|
189
|
+
>
|
|
190
|
+
<div class={DIALOG_HEAD}>
|
|
191
|
+
<h2 id="changelog-title" class="m-0 font-primary text-step3 font-bold">Co je nového</h2>
|
|
192
|
+
<button type="button" class={DIALOG_CLOSE} aria-label="Zavřít" onclick={() => dialogEl?.close()}>
|
|
193
|
+
<svg class="h-[18px] w-[18px]" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
<!-- The changelog is repo content (partials/changelog.html), inlined at
|
|
197
|
+
build time — not user input; {@html} is safe here. -->
|
|
198
|
+
<div class={DIALOG_BODY}>{@html changelog}</div>
|
|
199
|
+
</dialog>
|
|
200
|
+
{/if}
|
|
121
201
|
</footer>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
3
|
interface Props {
|
|
3
4
|
logo?: string;
|
|
@@ -5,6 +6,24 @@ interface Props {
|
|
|
5
6
|
slogan?: string;
|
|
6
7
|
meta?: string;
|
|
7
8
|
stamp?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Consumer content rendered inside the footer's black chrome, ABOVE the
|
|
11
|
+
* brand row (an e-shop's link columns, a newsletter box). The guide's
|
|
12
|
+
* own footer is the brand row alone, so this stays unset there and the
|
|
13
|
+
* markup is unchanged; an app footer is usually more than one strip,
|
|
14
|
+
* and without this slot it would have to stack its own black block on
|
|
15
|
+
* top of this one — two rounded-top black boxes with a seam between
|
|
16
|
+
* them. Same consumer-seam reasoning as SiteHeader's `barActions`.
|
|
17
|
+
*/
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
/**
|
|
20
|
+
* Task 45 — HTML of the changelog shown in a floating <dialog> when the
|
|
21
|
+
* reader clicks the deploy stamp. The showcase passes the build-time
|
|
22
|
+
* inline of partials/changelog.html (__CHANGELOG_HTML__, see
|
|
23
|
+
* vite.config.ts) — the same file the hand-written pages SSI-include.
|
|
24
|
+
* Without it (or without `stamp`) the stamp stays a plain line.
|
|
25
|
+
*/
|
|
26
|
+
changelog?: string;
|
|
8
27
|
class?: string;
|
|
9
28
|
}
|
|
10
29
|
type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import Button from './Button.svelte';
|
|
6
6
|
import { createChapterNavDock } from '../utils/chapter-nav-dock.svelte.js';
|
|
7
7
|
import { DEER_MARK_PATH } from './deer-mark-path.js';
|
|
8
|
-
import { isActive, type NavItem } from './nav.js';
|
|
8
|
+
import { isActive, type NavGroup, type NavItem } from './nav.js';
|
|
9
9
|
|
|
10
10
|
interface Props {
|
|
11
11
|
/**
|
|
@@ -78,6 +78,22 @@
|
|
|
78
78
|
* it is not a general slot.
|
|
79
79
|
*/
|
|
80
80
|
actions?: Snippet;
|
|
81
|
+
/**
|
|
82
|
+
* Controls that must stay visible IN THE BAR on a phone — an e-shop's
|
|
83
|
+
* cart button, a search trigger. `actions` cannot serve them: it lives
|
|
84
|
+
* inside the nav, which below 640px is the burger drawer, so anything
|
|
85
|
+
* in it costs a reader two taps and disappears from the bar entirely.
|
|
86
|
+
* The guide's own header has no such controls (its one nav-adjacent
|
|
87
|
+
* control, the theme toggle, belongs in `actions`), so this slot adds
|
|
88
|
+
* a consumer seam rather than reproducing guide markup.
|
|
89
|
+
*
|
|
90
|
+
* Rendered between the lockup and the burger, right-aligned, and
|
|
91
|
+
* HIDDEN from 640px up — on a desktop the same controls belong in
|
|
92
|
+
* `actions`, inside the nav row, and rendering both would double
|
|
93
|
+
* them. A consumer using this slot typically renders the same
|
|
94
|
+
* snippet content in both props.
|
|
95
|
+
*/
|
|
96
|
+
barActions?: Snippet;
|
|
81
97
|
class?: string;
|
|
82
98
|
}
|
|
83
99
|
|
|
@@ -94,6 +110,7 @@
|
|
|
94
110
|
menuLabel = 'Otevřít menu',
|
|
95
111
|
menuLabelClose = 'Zavřít menu',
|
|
96
112
|
actions,
|
|
113
|
+
barActions,
|
|
97
114
|
class: className,
|
|
98
115
|
...rest
|
|
99
116
|
}: Props & HTMLAttributes<HTMLElement> = $props();
|
|
@@ -116,6 +133,62 @@
|
|
|
116
133
|
let menuOpen = $state(false);
|
|
117
134
|
let menuButton = $state<HTMLButtonElement | null>(null);
|
|
118
135
|
let linksEl = $state<HTMLElement | null>(null);
|
|
136
|
+
let headerEl = $state<HTMLElement | null>(null);
|
|
137
|
+
|
|
138
|
+
// MEGA-MENU (≥640px): which section's panel is slid out, keyed by item
|
|
139
|
+
// href. Opens on hover or keyboard focus of the section link; closes with
|
|
140
|
+
// a 150ms grace on mouseleave — the pointer's path from the link down into
|
|
141
|
+
// the panel crosses the bar's bottom padding, and an immediate close would
|
|
142
|
+
// snap the panel shut before it arrives. Mirrors initSiteMenu() in
|
|
143
|
+
// assets/js/jelinek.js (same delay, same open-on-focus).
|
|
144
|
+
let openSub = $state<string | null>(null);
|
|
145
|
+
let subCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
146
|
+
function cancelSubClose() {
|
|
147
|
+
if (subCloseTimer) {
|
|
148
|
+
clearTimeout(subCloseTimer);
|
|
149
|
+
subCloseTimer = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function openSubmenu(href: string) {
|
|
153
|
+
cancelSubClose();
|
|
154
|
+
openSub = href;
|
|
155
|
+
}
|
|
156
|
+
function closeSubmenu() {
|
|
157
|
+
cancelSubClose();
|
|
158
|
+
openSub = null;
|
|
159
|
+
}
|
|
160
|
+
function scheduleSubClose() {
|
|
161
|
+
cancelSubClose();
|
|
162
|
+
subCloseTimer = setTimeout(() => (openSub = null), 150);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Escape closes the open panel and hands focus back to its section link
|
|
166
|
+
// (without that, focus inside the now-invisible panel drops to <body> and
|
|
167
|
+
// keyboard navigation restarts from the top — same WCAG 2.4.3 reasoning as
|
|
168
|
+
// closeMenu above). Focus leaving the header entirely closes it too.
|
|
169
|
+
$effect(() => {
|
|
170
|
+
if (!openSub) return;
|
|
171
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
172
|
+
if (event.key !== 'Escape') return;
|
|
173
|
+
const i = items.findIndex((it) => it.href === openSub);
|
|
174
|
+
closeSubmenu();
|
|
175
|
+
if (i >= 0) document.getElementById(`${uid}-link-${i}`)?.focus();
|
|
176
|
+
}
|
|
177
|
+
function onFocusIn(event: FocusEvent) {
|
|
178
|
+
const target = event.target as Node | null;
|
|
179
|
+
if (target && headerEl && !headerEl.contains(target)) closeSubmenu();
|
|
180
|
+
}
|
|
181
|
+
document.addEventListener('keydown', onKeyDown);
|
|
182
|
+
document.addEventListener('focusin', onFocusIn);
|
|
183
|
+
return () => {
|
|
184
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
185
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Phone accordion state (which sections are unfolded in the drawer),
|
|
190
|
+
// keyed by item href. Survives closing the drawer, like the guide's.
|
|
191
|
+
let openGroups = $state<Record<string, boolean>>({});
|
|
119
192
|
|
|
120
193
|
// Closing the panel while focus is still inside it would drop focus onto
|
|
121
194
|
// <body>, restarting keyboard navigation from the top of the page. Every
|
|
@@ -269,19 +342,35 @@
|
|
|
269
342
|
const OUTER =
|
|
270
343
|
'absolute inset-x-0 top-0 z-50 p-0 [--logo-mark:46px] ' +
|
|
271
344
|
'min-[640px]:fixed min-[640px]:px-6 min-[640px]:py-3';
|
|
345
|
+
// Task 43 — the glass card no longer IS the flex row: the row (logo,
|
|
346
|
+
// lockup, burger, nav) moved into its own .site-header__bar-equivalent
|
|
347
|
+
// wrapper (BAR below), so the card can grow BELOW the row by the open
|
|
348
|
+
// mega-menu panel — one background, one border, one shadow for the bar and
|
|
349
|
+
// the submenu together, exactly like partials/header.html's
|
|
350
|
+
// .site-header__inner / .site-header__bar split.
|
|
272
351
|
const INNER_BASE =
|
|
273
|
-
'mx-auto
|
|
352
|
+
'mx-auto max-w-none ' +
|
|
274
353
|
'border border-x-0 border-t-0 border-[var(--glass-border)] rounded-none ' +
|
|
275
354
|
'bg-[var(--glass-fill)] px-6 py-3 shadow-resting ' +
|
|
276
355
|
'dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)] ' +
|
|
277
356
|
'[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)] ' +
|
|
278
357
|
'min-[640px]:max-w-page min-[640px]:border-x min-[640px]:border-t ' +
|
|
279
358
|
'min-[640px]:rounded-card';
|
|
359
|
+
const BAR_ROW = 'flex items-center gap-[calc(var(--logo-mark)*107/400)]';
|
|
280
360
|
const INNER_JOINED =
|
|
281
361
|
'min-[640px]:rounded-b-none min-[640px]:border-b-0 min-[640px]:shadow-none ' +
|
|
282
362
|
'min-[640px]:dark:shadow-[inset_0_2px_1px_-1px_var(--box-primary)]';
|
|
283
363
|
const INNER_DETACHED = '';
|
|
284
|
-
|
|
364
|
+
// While a mega-menu is open the card has stretched OVER the chapter bar, so
|
|
365
|
+
// even a docked header gets its detached rounded bottom back (mirrors the
|
|
366
|
+
// guide's `.site-header.is-submenu-open .site-header__inner` rule). Placed
|
|
367
|
+
// after INNER_JOINED in cn() so tailwind-merge lets it win.
|
|
368
|
+
const INNER_SUBOPEN =
|
|
369
|
+
'min-[640px]:rounded-card min-[640px]:border-b min-[640px]:shadow-resting ' +
|
|
370
|
+
'min-[640px]:dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)]';
|
|
371
|
+
const inner = $derived(
|
|
372
|
+
cn(INNER_BASE, dock.docked ? INNER_JOINED : INNER_DETACHED, openSub ? INNER_SUBOPEN : '')
|
|
373
|
+
);
|
|
285
374
|
|
|
286
375
|
// jelinek.css:444-445 (.site-header__logo, .site-header__logo img /
|
|
287
376
|
// .site-header__mark): inline-flex, no shrink/grow, line-height:0
|
|
@@ -417,14 +506,91 @@
|
|
|
417
506
|
// `vh` is the tallest-possible viewport, which is exactly the amount by which
|
|
418
507
|
// the URL bar would hide the end of the list. `overscroll-contain` keeps a
|
|
419
508
|
// flick at the end of the list from scrolling the page underneath instead.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
509
|
+
// Task 43 — THE PHONE MENU IS A RIGHT-SIDE DRAWER, not a dropdown hung off
|
|
510
|
+
// the bar: full viewport height, slides in from the right (where the burger
|
|
511
|
+
// is), over a dimmed page. Mirrors jelinek.css's phone
|
|
512
|
+
// `:root.has-js .site-header__nav`. Closed, it is translated fully off the
|
|
513
|
+
// right edge AND `invisible`, so nothing inside is focusable; the
|
|
514
|
+
// visibility flip is delayed by the slide duration on the way out (the
|
|
515
|
+
// arbitrary [transition:…] pair below) so the exit animation stays visible.
|
|
516
|
+
const DRAWER =
|
|
517
|
+
'fixed inset-y-0 right-0 left-auto z-[3] flex w-[min(84vw,340px)] flex-col items-stretch gap-1 p-4 ' +
|
|
518
|
+
'border-l border-[var(--glass-border)] bg-[var(--glass-fill)] shadow-resting ' +
|
|
519
|
+
'[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)] ' +
|
|
520
|
+
'overflow-y-auto overscroll-contain min-[640px]:hidden';
|
|
521
|
+
const DRAWER_CLOSED =
|
|
522
|
+
'translate-x-full invisible ' +
|
|
523
|
+
'[transition:transform_var(--dur-slow)_var(--ease),visibility_0s_var(--dur-slow)]';
|
|
524
|
+
const DRAWER_OPEN = 'translate-x-0 visible [transition:transform_var(--dur-slow)_var(--ease)]';
|
|
525
|
+
const drawer = $derived(cn(DRAWER, menuOpen ? DRAWER_OPEN : DRAWER_CLOSED));
|
|
526
|
+
|
|
527
|
+
// The page dim under the open drawer. A real element rather than the
|
|
528
|
+
// guide's `.site-header::before` (a component can attach handlers to an
|
|
529
|
+
// element; a pseudo needs the guide's target===header trick). A tap on it
|
|
530
|
+
// is already "a pointerdown outside linksEl/menuButton", so the dismissal
|
|
531
|
+
// $effect above closes the drawer without a handler here.
|
|
532
|
+
const BACKDROP =
|
|
533
|
+
'fixed inset-0 z-[1] bg-black/30 min-[640px]:hidden ' +
|
|
534
|
+
'transition-opacity duration-[var(--dur-slow)] ease-[var(--ease)]';
|
|
535
|
+
const backdrop = $derived(cn(BACKDROP, menuOpen ? 'opacity-100' : 'pointer-events-none opacity-0'));
|
|
536
|
+
|
|
537
|
+
// Drawer head: small uppercase "Menu" + a 44px close cross. The drawer
|
|
538
|
+
// covers the bar (and the burger with it), so it closes itself.
|
|
539
|
+
const DRAWER_HEAD = 'flex items-center justify-between pb-2 pl-4 min-[640px]:hidden';
|
|
540
|
+
const DRAWER_LABEL = 'text-[0.8rem] font-semibold tracking-[0.08em] uppercase text-fg-muted1';
|
|
541
|
+
const DRAWER_CLOSE =
|
|
542
|
+
'inline-flex h-11 w-11 flex-none cursor-pointer items-center justify-center ' +
|
|
543
|
+
'rounded-button border-0 bg-transparent p-0 text-fg';
|
|
544
|
+
|
|
545
|
+
// Accordion (drawer): section row = link + a chevron that unfolds the
|
|
546
|
+
// chapters in place. Height animates via grid-template-rows 0fr → 1fr —
|
|
547
|
+
// the one way to animate to an automatic height.
|
|
548
|
+
const GROUP_ROW = 'flex items-center';
|
|
549
|
+
const CHEVRON =
|
|
550
|
+
'inline-flex h-10 w-11 flex-none cursor-pointer items-center justify-center ' +
|
|
551
|
+
'rounded-button border-0 bg-transparent p-0 text-fg';
|
|
552
|
+
const CHEVRON_ICON = 'h-4 w-4 transition-transform duration-[var(--dur-base)] ease-[var(--ease)]';
|
|
553
|
+
// Same closed/open mechanics as MEGA below (and the guide's shared
|
|
554
|
+
// .site-menu__panel rule): closed is ALSO invisible, so the clipped links
|
|
555
|
+
// drop out of the tab order; the flip back is delayed by the animation
|
|
556
|
+
// length so the fold-up stays visible.
|
|
557
|
+
const ACC_PANEL = 'grid overflow-hidden';
|
|
558
|
+
const ACC_CLOSED =
|
|
559
|
+
'invisible grid-rows-[0fr] ' +
|
|
560
|
+
'[transition:grid-template-rows_var(--dur-base)_var(--ease),visibility_0s_var(--dur-base)]';
|
|
561
|
+
const ACC_OPEN =
|
|
562
|
+
'visible grid-rows-[1fr] [transition:grid-template-rows_var(--dur-base)_var(--ease)]';
|
|
563
|
+
const ACC_INNER = 'flex min-h-0 flex-col gap-0.5 overflow-hidden pl-4';
|
|
564
|
+
|
|
565
|
+
// MEGA-MENU PANEL (≥640px): a child of the glass card itself, rendered
|
|
566
|
+
// below the BAR row — NOT a second glass surface hung outside it. The
|
|
567
|
+
// reader asked for the submenu to read as the bar stretching smoothly
|
|
568
|
+
// downward: one background, one border, one shadow. Opening animates
|
|
569
|
+
// grid-template-rows 0fr → 1fr (the accordion mechanic — the one way to
|
|
570
|
+
// animate to an automatic height), so the card grows with it. The panel
|
|
571
|
+
// carries NO glass of its own: glass nested in glass has nothing to blur.
|
|
572
|
+
// Closed it is also `invisible`, so its clipped links drop out of the tab
|
|
573
|
+
// order; the flip back is delayed by the animation length on the way out.
|
|
574
|
+
const MEGA = 'hidden overflow-hidden min-[640px]:grid';
|
|
575
|
+
const MEGA_CLOSED =
|
|
576
|
+
'invisible grid-rows-[0fr] ' +
|
|
577
|
+
'[transition:grid-template-rows_var(--dur-base)_var(--ease),visibility_0s_var(--dur-base)]';
|
|
578
|
+
const MEGA_OPEN =
|
|
579
|
+
'visible grid-rows-[1fr] [transition:grid-template-rows_var(--dur-base)_var(--ease)]';
|
|
580
|
+
// The breathing room above the columns appears WITH the opening and
|
|
581
|
+
// animates alongside the height — a permanent padding-top would hold the
|
|
582
|
+
// card taller even closed (0fr crushes content, padding it cannot).
|
|
583
|
+
const MEGA_COLS =
|
|
584
|
+
'grid min-h-0 grid-cols-3 gap-x-6 gap-y-2 pt-0 ' +
|
|
585
|
+
'transition-[padding-top] duration-[var(--dur-base)] ease-[var(--ease)]';
|
|
586
|
+
const MEGA_COLS_OPEN = 'pt-4';
|
|
587
|
+
const MEGA_COL = 'flex flex-col gap-0.5';
|
|
588
|
+
// Column heading (shared by the mega-menu and the accordion): the same
|
|
589
|
+
// small uppercase Avenir treatment as the header subtitle.
|
|
590
|
+
const HEADING =
|
|
591
|
+
'px-4 pt-2.5 pb-0.5 text-[0.72rem] font-semibold tracking-[0.08em] uppercase text-fg-muted1';
|
|
592
|
+
|
|
426
593
|
const NAV_ROW = 'hidden ml-auto items-center gap-2 min-[640px]:flex';
|
|
427
|
-
const navPanel = $derived(cn(NAV_PANEL, menuOpen ? 'flex' : 'hidden'));
|
|
428
594
|
|
|
429
595
|
// NAV LINKS (jelinek.css:472 + partials/header.html:6-8). His 36354b9 turned
|
|
430
596
|
// them into TERTIARY BUTTONS: the markup is literally
|
|
@@ -473,6 +639,14 @@
|
|
|
473
639
|
const BURGER =
|
|
474
640
|
'ml-auto inline-flex h-11 w-11 flex-none cursor-pointer flex-col justify-center gap-[5px] ' +
|
|
475
641
|
'rounded-button border-0 bg-transparent px-2.5 min-[640px]:hidden';
|
|
642
|
+
|
|
643
|
+
// The `barActions` slot (see its prop doc). `ml-auto` claims the bar's free
|
|
644
|
+
// space so the controls sit against the burger on the right; the burger then
|
|
645
|
+
// drops its OWN `ml-auto` (via the cn() override below) — two auto margins
|
|
646
|
+
// in one flex row would each take half the free space and strand the
|
|
647
|
+
// controls mid-bar. Phone-only: from 640px up the nav row's `ml-auto` owns
|
|
648
|
+
// the right side and these controls belong in `actions`.
|
|
649
|
+
const BAR_ACTIONS = 'ml-auto flex items-center gap-1 min-[640px]:hidden';
|
|
476
650
|
const BAR =
|
|
477
651
|
'block h-0.5 rounded-[2px] bg-fg ' +
|
|
478
652
|
'transition-[transform,opacity] duration-[var(--dur-base)] ease-[var(--ease)]';
|
|
@@ -507,26 +681,27 @@
|
|
|
507
681
|
// "render it yourself outside the component".
|
|
508
682
|
</script>
|
|
509
683
|
|
|
510
|
-
<!-- One definition of
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
{#
|
|
514
|
-
{#each items as
|
|
684
|
+
<!-- One definition of a submenu column's links, rendered into both the
|
|
685
|
+
mega-menu columns (≥640px) and the drawer accordion (phone). -->
|
|
686
|
+
{#snippet subLinks(group: NavGroup)}
|
|
687
|
+
{#if group.title}<div class={HEADING} style={SUBTITLE_STYLE}>{group.title}</div>{/if}
|
|
688
|
+
{#each group.items as chapter (chapter.href)}
|
|
515
689
|
<Button
|
|
516
|
-
href={
|
|
690
|
+
href={chapter.href}
|
|
517
691
|
variant="ghost"
|
|
518
692
|
color="black"
|
|
519
|
-
active={isActive(
|
|
520
|
-
aria-current={isActive(
|
|
521
|
-
class={
|
|
693
|
+
active={isActive(chapter, active)}
|
|
694
|
+
aria-current={isActive(chapter, active) ? 'page' : undefined}
|
|
695
|
+
class={CHAPTER_LINK}
|
|
522
696
|
>
|
|
523
|
-
{
|
|
697
|
+
{chapter.label}
|
|
524
698
|
</Button>
|
|
525
699
|
{/each}
|
|
526
700
|
{/snippet}
|
|
527
701
|
|
|
528
|
-
<header {...rest} class={cn(OUTER, className)}>
|
|
702
|
+
<header {...rest} bind:this={headerEl} class={cn(OUTER, className)}>
|
|
529
703
|
<div class={inner}>
|
|
704
|
+
<div class={BAR_ROW}>
|
|
530
705
|
<a href={logoHref} aria-label={logoAlt} class={LOGO_LINK}>
|
|
531
706
|
{#if logo}
|
|
532
707
|
{@render logo()}
|
|
@@ -557,6 +732,9 @@
|
|
|
557
732
|
{/if}
|
|
558
733
|
</a>
|
|
559
734
|
<div class={TITLE}>JELÍNEK{#if subtitle}<span class={SUBTITLE} style={SUBTITLE_STYLE}>{subtitle}</span>{/if}</div>
|
|
735
|
+
{#if barActions}
|
|
736
|
+
<div class={BAR_ACTIONS}>{@render barActions()}</div>
|
|
737
|
+
{/if}
|
|
560
738
|
{#if items.length > 0}
|
|
561
739
|
<!-- The burger precedes the panel in source order so Tab from it lands on
|
|
562
740
|
the first link it just revealed, and because that is the order in
|
|
@@ -564,7 +742,7 @@
|
|
|
564
742
|
<button
|
|
565
743
|
type="button"
|
|
566
744
|
bind:this={menuButton}
|
|
567
|
-
class={BURGER}
|
|
745
|
+
class={cn(BURGER, barActions !== undefined && 'ml-0')}
|
|
568
746
|
aria-expanded={menuOpen}
|
|
569
747
|
aria-controls={panelId}
|
|
570
748
|
aria-label={menuOpen ? menuLabelClose : menuLabel}
|
|
@@ -574,19 +752,153 @@
|
|
|
574
752
|
<span class={bar2}></span>
|
|
575
753
|
<span class={bar3}></span>
|
|
576
754
|
</button>
|
|
577
|
-
<!-- The ≥640px row. Inside the glass bar, so it is a flex item of it.
|
|
755
|
+
<!-- The ≥640px row. Inside the glass bar, so it is a flex item of it.
|
|
756
|
+
Section links with a submenu open their mega-menu panel on hover or
|
|
757
|
+
keyboard focus; the click still navigates (to the section's first
|
|
758
|
+
chapter), so the link works without a mouse and on touch. -->
|
|
578
759
|
<nav class={NAV_ROW} aria-label={navLabel}>
|
|
579
|
-
{
|
|
760
|
+
{#each items as item, i (item.href)}
|
|
761
|
+
{#if item.submenu}
|
|
762
|
+
<!-- A BUTTON, not a link — the owner's call: the first level
|
|
763
|
+
never navigates. Hover/focus slides the panel out; click
|
|
764
|
+
only opens it too (not a toggle — the focus that precedes
|
|
765
|
+
a click already opened it, so a toggle would snap it shut).
|
|
766
|
+
Esc / mouse-out / clicking elsewhere closes. -->
|
|
767
|
+
<Button
|
|
768
|
+
variant="ghost"
|
|
769
|
+
color="black"
|
|
770
|
+
active={isActive(item, active)}
|
|
771
|
+
aria-current={isActive(item, active) ? 'page' : undefined}
|
|
772
|
+
aria-expanded={openSub === item.href}
|
|
773
|
+
aria-controls={`${uid}-sub-${i}`}
|
|
774
|
+
id={`${uid}-link-${i}`}
|
|
775
|
+
class={NAV_LINK}
|
|
776
|
+
onmouseenter={() => openSubmenu(item.href)}
|
|
777
|
+
onmouseleave={scheduleSubClose}
|
|
778
|
+
onfocus={() => openSubmenu(item.href)}
|
|
779
|
+
onclick={() => openSubmenu(item.href)}
|
|
780
|
+
>
|
|
781
|
+
{item.label}
|
|
782
|
+
</Button>
|
|
783
|
+
{:else}
|
|
784
|
+
<Button
|
|
785
|
+
href={item.href}
|
|
786
|
+
variant="ghost"
|
|
787
|
+
color="black"
|
|
788
|
+
active={isActive(item, active)}
|
|
789
|
+
aria-current={isActive(item, active) ? 'page' : undefined}
|
|
790
|
+
class={NAV_LINK}
|
|
791
|
+
>
|
|
792
|
+
{item.label}
|
|
793
|
+
</Button>
|
|
794
|
+
{/if}
|
|
795
|
+
{/each}
|
|
580
796
|
{#if actions}{@render actions()}{/if}
|
|
581
797
|
</nav>
|
|
582
798
|
{/if}
|
|
799
|
+
</div>
|
|
800
|
+
<!-- Mega-menu panels (≥640px): children of the glass card, below the bar
|
|
801
|
+
row — the card stretches down with the open panel, so the bar and the
|
|
802
|
+
submenu share one background, border and shadow. Exactly one can be
|
|
803
|
+
open at a time (openSub). -->
|
|
804
|
+
{#each items as item, i (item.href)}
|
|
805
|
+
{#if item.submenu}
|
|
806
|
+
<!-- role="presentation": the wrapper is a hover surface; the links
|
|
807
|
+
inside carry the semantics (the keyboard path never needs the
|
|
808
|
+
mouse handlers — focus opens/closes the panel via the link). -->
|
|
809
|
+
<div
|
|
810
|
+
id={`${uid}-sub-${i}`}
|
|
811
|
+
role="presentation"
|
|
812
|
+
class={cn(MEGA, openSub === item.href ? MEGA_OPEN : MEGA_CLOSED)}
|
|
813
|
+
onmouseenter={cancelSubClose}
|
|
814
|
+
onmouseleave={scheduleSubClose}
|
|
815
|
+
>
|
|
816
|
+
<!-- Columns = the section's group count. The guide's own rule is a fixed
|
|
817
|
+
repeat(3, 1fr) (jelinek.css .site-menu__cols) because both its sections
|
|
818
|
+
have exactly three groups; a consumer's section may have four or two, and
|
|
819
|
+
a fixed 3 would wrap the surplus group onto a lonely second row. The
|
|
820
|
+
inline style computes to the identical value for the guide's own pages. -->
|
|
821
|
+
<div
|
|
822
|
+
class={cn(MEGA_COLS, openSub === item.href && MEGA_COLS_OPEN)}
|
|
823
|
+
style={`grid-template-columns:repeat(${item.submenu.length},minmax(0,1fr))`}
|
|
824
|
+
>
|
|
825
|
+
{#each item.submenu as group, gi (group.title ?? gi)}
|
|
826
|
+
<div class={MEGA_COL}>
|
|
827
|
+
{@render subLinks(group)}
|
|
828
|
+
</div>
|
|
829
|
+
{/each}
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
{/if}
|
|
833
|
+
{/each}
|
|
583
834
|
</div>
|
|
584
835
|
{#if items.length > 0}
|
|
585
|
-
<!-- The
|
|
586
|
-
|
|
836
|
+
<!-- The page dim under the open drawer (phone only). Its own element so
|
|
837
|
+
it can sit between the page and the drawer; a tap on it is a
|
|
838
|
+
pointerdown outside linksEl, so the dismissal effect closes the menu. -->
|
|
839
|
+
<div class={backdrop} aria-hidden="true"></div>
|
|
840
|
+
<!-- The phone drawer. A SIBLING of the glass bar, not a child — see
|
|
841
|
+
DRAWER's comment for the measurement that forced that. Hidden from
|
|
587
842
|
640px up, so only one of the two navs is ever in the a11y tree. -->
|
|
588
|
-
<nav id={panelId} bind:this={linksEl} class={
|
|
589
|
-
{
|
|
843
|
+
<nav id={panelId} bind:this={linksEl} class={drawer} aria-label={navLabel}>
|
|
844
|
+
<div class={DRAWER_HEAD}>
|
|
845
|
+
<span class={DRAWER_LABEL} style={SUBTITLE_STYLE}>Menu</span>
|
|
846
|
+
<button type="button" class={DRAWER_CLOSE} aria-label={menuLabelClose} onclick={closeMenu}>
|
|
847
|
+
<svg class="h-[18px] w-[18px]" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
|
848
|
+
</button>
|
|
849
|
+
</div>
|
|
850
|
+
{#each items as item, i (item.href)}
|
|
851
|
+
{#if item.submenu}
|
|
852
|
+
<div>
|
|
853
|
+
<div class={GROUP_ROW}>
|
|
854
|
+
<!-- The label is a BUTTON too: tapping it unfolds the
|
|
855
|
+
chapters, same as the chevron (the owner's call — a
|
|
856
|
+
navigating first level was easy to hit by mistake on
|
|
857
|
+
a phone). The chevron stays as the visual cue. -->
|
|
858
|
+
<Button
|
|
859
|
+
variant="ghost"
|
|
860
|
+
color="black"
|
|
861
|
+
active={isActive(item, active)}
|
|
862
|
+
aria-current={isActive(item, active) ? 'page' : undefined}
|
|
863
|
+
aria-expanded={!!openGroups[item.href]}
|
|
864
|
+
aria-controls={`${uid}-acc-${i}`}
|
|
865
|
+
class={cn(NAV_LINK, 'grow')}
|
|
866
|
+
onclick={() => (openGroups[item.href] = !openGroups[item.href])}
|
|
867
|
+
>
|
|
868
|
+
{item.label}
|
|
869
|
+
</Button>
|
|
870
|
+
<button
|
|
871
|
+
type="button"
|
|
872
|
+
class={CHEVRON}
|
|
873
|
+
aria-expanded={!!openGroups[item.href]}
|
|
874
|
+
aria-controls={`${uid}-acc-${i}`}
|
|
875
|
+
aria-label={`Rozbalit kapitoly sekce ${item.label}`}
|
|
876
|
+
onclick={() => (openGroups[item.href] = !openGroups[item.href])}
|
|
877
|
+
>
|
|
878
|
+
<svg class={cn(CHEVRON_ICON, openGroups[item.href] && 'rotate-180')} viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 6 4.5 4.5L12.5 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
879
|
+
</button>
|
|
880
|
+
</div>
|
|
881
|
+
<div id={`${uid}-acc-${i}`} class={cn(ACC_PANEL, openGroups[item.href] ? ACC_OPEN : ACC_CLOSED)}>
|
|
882
|
+
<div class={ACC_INNER}>
|
|
883
|
+
{#each item.submenu as group, gi (group.title ?? gi)}
|
|
884
|
+
{@render subLinks(group)}
|
|
885
|
+
{/each}
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
{:else}
|
|
890
|
+
<Button
|
|
891
|
+
href={item.href}
|
|
892
|
+
variant="ghost"
|
|
893
|
+
color="black"
|
|
894
|
+
active={isActive(item, active)}
|
|
895
|
+
aria-current={isActive(item, active) ? 'page' : undefined}
|
|
896
|
+
class={NAV_LINK}
|
|
897
|
+
>
|
|
898
|
+
{item.label}
|
|
899
|
+
</Button>
|
|
900
|
+
{/if}
|
|
901
|
+
{/each}
|
|
590
902
|
{#if chapterItems.length > 0}
|
|
591
903
|
<div class={CHAPTERS}>
|
|
592
904
|
{#each chapterItems as chapter (chapter.href)}
|
|
@@ -72,6 +72,22 @@ interface Props {
|
|
|
72
72
|
* it is not a general slot.
|
|
73
73
|
*/
|
|
74
74
|
actions?: Snippet;
|
|
75
|
+
/**
|
|
76
|
+
* Controls that must stay visible IN THE BAR on a phone — an e-shop's
|
|
77
|
+
* cart button, a search trigger. `actions` cannot serve them: it lives
|
|
78
|
+
* inside the nav, which below 640px is the burger drawer, so anything
|
|
79
|
+
* in it costs a reader two taps and disappears from the bar entirely.
|
|
80
|
+
* The guide's own header has no such controls (its one nav-adjacent
|
|
81
|
+
* control, the theme toggle, belongs in `actions`), so this slot adds
|
|
82
|
+
* a consumer seam rather than reproducing guide markup.
|
|
83
|
+
*
|
|
84
|
+
* Rendered between the lockup and the burger, right-aligned, and
|
|
85
|
+
* HIDDEN from 640px up — on a desktop the same controls belong in
|
|
86
|
+
* `actions`, inside the nav row, and rendering both would double
|
|
87
|
+
* them. A consumer using this slot typically renders the same
|
|
88
|
+
* snippet content in both props.
|
|
89
|
+
*/
|
|
90
|
+
barActions?: Snippet;
|
|
75
91
|
class?: string;
|
|
76
92
|
}
|
|
77
93
|
type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
|
|
@@ -62,9 +62,16 @@
|
|
|
62
62
|
// its solid styling even while hovered — reproduced here by only adding the
|
|
63
63
|
// hover: classes to the unselected branch, never to the selected one.
|
|
64
64
|
// Neither branch needs an explicit dark: override: bg-jelinek-black /
|
|
65
|
-
// text-on-jelinek-black /
|
|
65
|
+
// text-on-jelinek-black / bg-fg-muted1 / text-fg-inverse
|
|
66
66
|
// all resolve through theme-aware CSS custom properties that already flip
|
|
67
67
|
// under [data-theme="dark"]/.dark.
|
|
68
|
+
//
|
|
69
|
+
// The INACTIVE segment is --JELINEK-BLACK (text-jelinek-black), not
|
|
70
|
+
// --text-muted1 — the guide changed .tab to match its tertiary buttons
|
|
71
|
+
// (.btn--ghost.btn--black) after the muted grey washed out over the
|
|
72
|
+
// logo-tool photo. Marked provisional in jelinek.css: the pill/button
|
|
73
|
+
// relationship gets its final definition with the button-system design
|
|
74
|
+
// (docs/chybejici-komponenty.md).
|
|
68
75
|
</script>
|
|
69
76
|
|
|
70
77
|
<button
|
|
@@ -80,7 +87,7 @@
|
|
|
80
87
|
BASE,
|
|
81
88
|
selected
|
|
82
89
|
? 'bg-jelinek-black text-on-jelinek-black'
|
|
83
|
-
: 'bg-transparent text-
|
|
90
|
+
: 'bg-transparent text-jelinek-black hover:bg-fg-muted1 hover:text-fg-inverse',
|
|
84
91
|
className
|
|
85
92
|
)}
|
|
86
93
|
>
|
|
@@ -13,4 +13,4 @@
|
|
|
13
13
|
* fill="currentColor" so only the antlers — not the square behind them —
|
|
14
14
|
* follow the content division), only the path data itself is identical.
|
|
15
15
|
*/
|
|
16
|
-
export declare const DEER_MARK_PATH = "
|
|
16
|
+
export declare const DEER_MARK_PATH = "M220.3,196.1h23c.9,0,1.8,0,2.6,0,10.9,0,23.3.1,31.8-8.5,5.9-6,8.8-15.1,8.8-27.7h-11c0,9.4-1.9,16-5.5,19.8-5.3,5.3-14.8,5.3-24,5.2-.9,0-1.8,0-2.7,0h-11.9v-29.1h-11v29.1h-20.1l-47.7-26.9v-41.8h-11v35.6l-7.9-4.4h-.2c-.5-.4-46.5-23.9-47-60.1l-11,.2c.6,42.5,49.6,68.1,53.1,69.9l49,27.6h-35.9v5.6c0,10.9,4.4,17.1,10,22.1,5.6,5,19.9,4.9,19.9,4.9h16.8v-11.2h-19.9c-8.3,0-13.4-3.4-15.1-10.1h44.3l69.3,36.7v23.6l-28.3-10.9-2.1-.4-85.9,46.5v11.9l2.3-.3,86.5-46.5,29.5,11.5,9.1-4.1v-34.7l-2.9-4.9-54.7-28.5Z";
|
|
@@ -13,4 +13,8 @@
|
|
|
13
13
|
* fill="currentColor" so only the antlers — not the square behind them —
|
|
14
14
|
* follow the content division), only the path data itself is identical.
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
// Refreshed 2026-07-29 from the new master files the user uploaded to
|
|
17
|
+
// assets/logos/mark/Digital/ (path taken verbatim from Mark-RGB_NOBKG-BLK.svg;
|
|
18
|
+
// every variant in the set carries the identical path). partials/header.html:4
|
|
19
|
+
// was updated in the same change — the two must keep matching.
|
|
20
|
+
export const DEER_MARK_PATH = 'M220.3,196.1h23c.9,0,1.8,0,2.6,0,10.9,0,23.3.1,31.8-8.5,5.9-6,8.8-15.1,8.8-27.7h-11c0,9.4-1.9,16-5.5,19.8-5.3,5.3-14.8,5.3-24,5.2-.9,0-1.8,0-2.7,0h-11.9v-29.1h-11v29.1h-20.1l-47.7-26.9v-41.8h-11v35.6l-7.9-4.4h-.2c-.5-.4-46.5-23.9-47-60.1l-11,.2c.6,42.5,49.6,68.1,53.1,69.9l49,27.6h-35.9v5.6c0,10.9,4.4,17.1,10,22.1,5.6,5,19.9,4.9,19.9,4.9h16.8v-11.2h-19.9c-8.3,0-13.4-3.4-15.1-10.1h44.3l69.3,36.7v23.6l-28.3-10.9-2.1-.4-85.9,46.5v11.9l2.3-.3,86.5-46.5,29.5,11.5,9.1-4.1v-34.7l-2.9-4.9-54.7-28.5Z';
|
package/dist/components/nav.d.ts
CHANGED
|
@@ -16,6 +16,27 @@ export type NavItem = {
|
|
|
16
16
|
* special-cased to an exact compare — see isActive() in SiteHeader.
|
|
17
17
|
*/
|
|
18
18
|
match?: 'exact' | 'prefix';
|
|
19
|
+
/**
|
|
20
|
+
* Compare `active` against this path instead of `href`. For a section link
|
|
21
|
+
* whose href jumps straight to the section's first chapter (the way
|
|
22
|
+
* partials/header.html sends "Brand guide" to /brand/znacka.html) but which
|
|
23
|
+
* must stay lit on every page of the section, not just that chapter.
|
|
24
|
+
*/
|
|
25
|
+
matchHref?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Chapters of this section, split into columns. On a desktop the columns
|
|
28
|
+
* make up the section's mega-menu panel (slides out from under the header
|
|
29
|
+
* bar on hover/focus); on a phone they flatten into one accordion list
|
|
30
|
+
* under the section's row in the drawer. A column's `title` renders as a
|
|
31
|
+
* small uppercase heading above its links (the UI Kit groups its pages;
|
|
32
|
+
* the brand guide's plain chapter list leaves it unset). Mirrors the
|
|
33
|
+
* `.site-menu__group`/`.site-menu__panel` markup in partials/header.html.
|
|
34
|
+
*/
|
|
35
|
+
submenu?: NavGroup[];
|
|
36
|
+
};
|
|
37
|
+
export type NavGroup = {
|
|
38
|
+
title?: string;
|
|
39
|
+
items: NavItem[];
|
|
19
40
|
};
|
|
20
41
|
/**
|
|
21
42
|
* Does `pathname` count as being on `item`? Exported because SiteHeader renders
|
package/dist/components/nav.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
export function isActive(item, pathname) {
|
|
7
7
|
if (pathname === undefined)
|
|
8
8
|
return false;
|
|
9
|
+
const target = item.matchHref ?? item.href;
|
|
9
10
|
if (item.match !== 'prefix')
|
|
10
|
-
return
|
|
11
|
+
return target === pathname;
|
|
11
12
|
// A bare "/" prefix would match every path, which is never what a section
|
|
12
13
|
// link means. Compare exactly instead.
|
|
13
|
-
const base =
|
|
14
|
+
const base = target.replace(/\/$/, '');
|
|
14
15
|
if (base === '')
|
|
15
16
|
return pathname === '/' || pathname === '';
|
|
16
17
|
return pathname === base || pathname.startsWith(`${base}/`);
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { default as Eyebrow } from './components/Eyebrow.svelte';
|
|
|
14
14
|
export { default as Glass } from './components/Glass.svelte';
|
|
15
15
|
export { default as Grid } from './components/Grid.svelte';
|
|
16
16
|
export { default as Label } from './components/Label.svelte';
|
|
17
|
-
export { isActive, type NavItem } from './components/nav.js';
|
|
17
|
+
export { isActive, type NavItem, type NavGroup } from './components/nav.js';
|
|
18
18
|
export { default as PageHero } from './components/PageHero.svelte';
|
|
19
19
|
export { default as Pager } from './components/Pager.svelte';
|
|
20
20
|
export { default as Panel } from './components/Panel.svelte';
|