@aiaiai-pt/design-system 0.32.0 → 0.33.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/components/SectionNavigation.svelte +185 -0
- package/components/SectionNavigation.svelte.d.ts +60 -0
- package/components/ServiceNavigation.svelte +38 -14
- package/components/SiteHeader.svelte +260 -11
- package/components/index.js +1 -0
- package/package.json +1 -1
- package/tokens/components.css +29 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component SectionNavigation
|
|
3
|
+
|
|
4
|
+
Contextual SECONDARY navigation for the public portal — the band that sits
|
|
5
|
+
directly under the `SiteHeader` primary row and surfaces the pages of the
|
|
6
|
+
section the visitor is currently inside (e.g. inside "Ocorrências": Reportar /
|
|
7
|
+
Consultar / Acompanhar). It is the second tier of a two-level information
|
|
8
|
+
architecture; the first tier (the sections themselves) lives in the header's
|
|
9
|
+
`ServiceNavigation`.
|
|
10
|
+
|
|
11
|
+
Why a separate band instead of a second inline nav: stacking two navs in one
|
|
12
|
+
header row makes the row reflow sideways every time the visitor crosses into
|
|
13
|
+
or out of a section. This component owns its OWN full-width band and animates
|
|
14
|
+
its disclosure (grid-rows 0fr→1fr) BELOW the fixed header, so the primary tier
|
|
15
|
+
never moves and there is no layout shift on the content above it. Render it in
|
|
16
|
+
`SiteHeader`'s `subnav` snippet.
|
|
17
|
+
|
|
18
|
+
Information architecture (GOV.UK / National Archives secondary-navigation
|
|
19
|
+
pattern): the band lists ONLY the section's pages — it does NOT repeat the
|
|
20
|
+
section name as a visible label. The section identity is carried by the active
|
|
21
|
+
PRIMARY item directly above it, and exposed to assistive tech via the nav
|
|
22
|
+
landmark's `aria-label` (the section name). The pages read one step quieter
|
|
23
|
+
than the primary tier (smaller text), and the current page gets a single
|
|
24
|
+
accent bar flush to the band's bottom border — the same device the primary
|
|
25
|
+
tier uses, one level down, so the two tiers read as one calm system.
|
|
26
|
+
|
|
27
|
+
Accessibility:
|
|
28
|
+
- `<nav aria-label={label}>` landmark — pass the localized SECTION name; it
|
|
29
|
+
is announced to screen readers but never rendered as a visible chip.
|
|
30
|
+
- The current page carries `aria-current="page"`.
|
|
31
|
+
- Empty `items` → the band collapses to zero height and is `aria-hidden`,
|
|
32
|
+
so it is never an empty landmark and never a dead row.
|
|
33
|
+
- Hidden below `--breakpoint`: on mobile the section's pages live in the
|
|
34
|
+
header's single unified disclosure (`SiteHeader` `menu` snippet), so there
|
|
35
|
+
is no second mobile toggle.
|
|
36
|
+
|
|
37
|
+
@example
|
|
38
|
+
<SectionNavigation
|
|
39
|
+
label="Ocorrências"
|
|
40
|
+
items={[
|
|
41
|
+
{ href: "/ocorrencias/submit", label: "Reportar ocorrência", current: true },
|
|
42
|
+
{ href: "/ocorrencias/track", label: "Acompanhar" },
|
|
43
|
+
]}
|
|
44
|
+
/>
|
|
45
|
+
-->
|
|
46
|
+
<script>
|
|
47
|
+
let {
|
|
48
|
+
/** @type {Array<{ href: string, label: string, current?: boolean }>} */
|
|
49
|
+
items = [],
|
|
50
|
+
/** @type {string} The section's (localized) name — the nav landmark's
|
|
51
|
+
* aria-label (announced, never rendered as a visible label). */
|
|
52
|
+
label = "",
|
|
53
|
+
/** @type {string} */
|
|
54
|
+
class: className = "",
|
|
55
|
+
...rest
|
|
56
|
+
} = $props();
|
|
57
|
+
|
|
58
|
+
const open = $derived(items.length > 0);
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<div class="section-nav-band {className}" class:open aria-hidden={!open} {...rest}>
|
|
62
|
+
<div class="section-nav-clip">
|
|
63
|
+
<nav class="section-nav-inner" aria-label={label || "Section"}>
|
|
64
|
+
<ul class="section-nav-list">
|
|
65
|
+
{#each items as item (item.href)}
|
|
66
|
+
<li class="section-nav-item">
|
|
67
|
+
<a
|
|
68
|
+
href={item.href}
|
|
69
|
+
class="section-nav-link"
|
|
70
|
+
aria-current={item.current ? "page" : undefined}
|
|
71
|
+
>
|
|
72
|
+
{item.label}
|
|
73
|
+
</a>
|
|
74
|
+
</li>
|
|
75
|
+
{/each}
|
|
76
|
+
</ul>
|
|
77
|
+
</nav>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
/* Full-width band on the page surface, set apart by a hairline only (no
|
|
83
|
+
competing fill). Animates its disclosure via grid-template-rows so opening /
|
|
84
|
+
closing is a vertical reveal, never a sideways shove of the header tier. */
|
|
85
|
+
.section-nav-band {
|
|
86
|
+
display: grid;
|
|
87
|
+
grid-template-rows: 0fr;
|
|
88
|
+
background: var(--color-surface);
|
|
89
|
+
border-bottom: var(--border-width) solid transparent;
|
|
90
|
+
transition:
|
|
91
|
+
grid-template-rows var(--duration-normal) var(--easing-enter),
|
|
92
|
+
border-color var(--duration-normal) var(--easing-default);
|
|
93
|
+
}
|
|
94
|
+
.section-nav-band.open {
|
|
95
|
+
grid-template-rows: 1fr;
|
|
96
|
+
border-bottom-color: var(--color-border);
|
|
97
|
+
}
|
|
98
|
+
.section-nav-clip {
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
min-height: 0;
|
|
101
|
+
}
|
|
102
|
+
/* The inner band is the nav landmark itself — fixed gridded height so the
|
|
103
|
+
links can fill it and the active bar sits flush on the bottom border. */
|
|
104
|
+
.section-nav-inner {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: stretch;
|
|
107
|
+
height: var(--nav-section-height);
|
|
108
|
+
width: 100%;
|
|
109
|
+
max-width: var(--content-width-wide);
|
|
110
|
+
margin-inline: auto;
|
|
111
|
+
padding-inline: var(--content-padding-x);
|
|
112
|
+
/* Content fades in just behind the height reveal. */
|
|
113
|
+
opacity: 0;
|
|
114
|
+
transform: translateY(calc(-1 * var(--space-xs)));
|
|
115
|
+
transition:
|
|
116
|
+
opacity var(--duration-normal) var(--easing-default),
|
|
117
|
+
transform var(--duration-normal) var(--easing-default);
|
|
118
|
+
}
|
|
119
|
+
.section-nav-band.open .section-nav-inner {
|
|
120
|
+
opacity: 1;
|
|
121
|
+
transform: none;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.section-nav-list {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: stretch;
|
|
127
|
+
height: 100%;
|
|
128
|
+
margin: 0;
|
|
129
|
+
padding: 0;
|
|
130
|
+
list-style: none;
|
|
131
|
+
/* Long sections scroll horizontally inside the band rather than wrapping
|
|
132
|
+
into a second row (which would reintroduce the height jump). */
|
|
133
|
+
overflow-x: auto;
|
|
134
|
+
scrollbar-width: none;
|
|
135
|
+
}
|
|
136
|
+
.section-nav-list::-webkit-scrollbar {
|
|
137
|
+
display: none;
|
|
138
|
+
}
|
|
139
|
+
.section-nav-item {
|
|
140
|
+
display: flex;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* One step quieter than the primary tier: smaller text, full height so the
|
|
144
|
+
active bar is flush on the band's bottom border. Horizontal padding only;
|
|
145
|
+
the first item aligns to the content edge (under the primary tier). */
|
|
146
|
+
.section-nav-link {
|
|
147
|
+
display: inline-flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
height: 100%;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
padding-inline: var(--space-md);
|
|
152
|
+
color: var(--color-text-secondary);
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
font-family: var(--type-label-font);
|
|
155
|
+
font-size: var(--nav-section-link-size);
|
|
156
|
+
font-weight: var(--raw-font-weight-medium);
|
|
157
|
+
transition: color var(--duration-fast) var(--easing-default);
|
|
158
|
+
}
|
|
159
|
+
.section-nav-item:first-child .section-nav-link {
|
|
160
|
+
padding-inline-start: 0;
|
|
161
|
+
}
|
|
162
|
+
.section-nav-link:hover {
|
|
163
|
+
color: var(--color-text);
|
|
164
|
+
}
|
|
165
|
+
.section-nav-link:focus-visible {
|
|
166
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
167
|
+
outline-offset: var(--focus-ring-offset);
|
|
168
|
+
}
|
|
169
|
+
/* Current page: emphasized text + accent underline bar (shared
|
|
170
|
+
--nav-service-underline token), flush on the band's bottom border. */
|
|
171
|
+
.section-nav-link[aria-current="page"] {
|
|
172
|
+
color: var(--color-text);
|
|
173
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
174
|
+
box-shadow: inset 0 calc(-1 * var(--nav-service-underline)) 0 0
|
|
175
|
+
var(--color-accent);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Mobile: the band is suppressed — the section's pages are reachable through
|
|
179
|
+
SiteHeader's single unified disclosure, so there is no second toggle. */
|
|
180
|
+
@media (max-width: 47.99rem) {
|
|
181
|
+
.section-nav-band {
|
|
182
|
+
display: none;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export default SectionNavigation;
|
|
2
|
+
type SectionNavigation = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* SectionNavigation
|
|
8
|
+
*
|
|
9
|
+
* Contextual SECONDARY navigation for the public portal — the band that sits
|
|
10
|
+
* directly under the `SiteHeader` primary row and surfaces the pages of the
|
|
11
|
+
* section the visitor is currently inside (e.g. inside "Ocorrências": Reportar /
|
|
12
|
+
* Consultar / Acompanhar). It is the second tier of a two-level information
|
|
13
|
+
* architecture; the first tier (the sections themselves) lives in the header's
|
|
14
|
+
* `ServiceNavigation`.
|
|
15
|
+
*
|
|
16
|
+
* Why a separate band instead of a second inline nav: stacking two navs in one
|
|
17
|
+
* header row makes the row reflow sideways every time the visitor crosses into
|
|
18
|
+
* or out of a section. This component owns its OWN full-width band and animates
|
|
19
|
+
* its disclosure (grid-rows 0fr→1fr) BELOW the fixed header, so the primary tier
|
|
20
|
+
* never moves and there is no layout shift on the content above it. Render it in
|
|
21
|
+
* `SiteHeader`'s `subnav` snippet.
|
|
22
|
+
*
|
|
23
|
+
* Information architecture (GOV.UK / National Archives secondary-navigation
|
|
24
|
+
* pattern): the band lists ONLY the section's pages — it does NOT repeat the
|
|
25
|
+
* section name as a visible label. The section identity is carried by the active
|
|
26
|
+
* PRIMARY item directly above it, and exposed to assistive tech via the nav
|
|
27
|
+
* landmark's `aria-label` (the section name). The pages read one step quieter
|
|
28
|
+
* than the primary tier (smaller text), and the current page gets a single
|
|
29
|
+
* accent bar flush to the band's bottom border — the same device the primary
|
|
30
|
+
* tier uses, one level down, so the two tiers read as one calm system.
|
|
31
|
+
*
|
|
32
|
+
* Accessibility:
|
|
33
|
+
* - `<nav aria-label={label}>` landmark — pass the localized SECTION name; it
|
|
34
|
+
* is announced to screen readers but never rendered as a visible chip.
|
|
35
|
+
* - The current page carries `aria-current="page"`.
|
|
36
|
+
* - Empty `items` → the band collapses to zero height and is `aria-hidden`,
|
|
37
|
+
* so it is never an empty landmark and never a dead row.
|
|
38
|
+
* - Hidden below `--breakpoint`: on mobile the section's pages live in the
|
|
39
|
+
* header's single unified disclosure (`SiteHeader` `menu` snippet), so there
|
|
40
|
+
* is no second mobile toggle.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* <SectionNavigation
|
|
44
|
+
* label="Ocorrências"
|
|
45
|
+
* items={[
|
|
46
|
+
* { href: "/ocorrencias/submit", label: "Reportar ocorrência", current: true },
|
|
47
|
+
* { href: "/ocorrencias/track", label: "Acompanhar" },
|
|
48
|
+
* ]}
|
|
49
|
+
* />
|
|
50
|
+
*/
|
|
51
|
+
declare const SectionNavigation: import("svelte").Component<{
|
|
52
|
+
items?: any[];
|
|
53
|
+
label?: string;
|
|
54
|
+
class?: string;
|
|
55
|
+
} & Record<string, any>, {}, "">;
|
|
56
|
+
type $$ComponentProps = {
|
|
57
|
+
items?: any[];
|
|
58
|
+
label?: string;
|
|
59
|
+
class?: string;
|
|
60
|
+
} & Record<string, any>;
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
<style>
|
|
79
79
|
.service-nav {
|
|
80
80
|
display: flex;
|
|
81
|
-
align-items:
|
|
81
|
+
align-items: stretch;
|
|
82
|
+
height: 100%;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/* Toggle hidden on wide viewports; the list is a horizontal row. */
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
align-items: center;
|
|
88
89
|
gap: var(--space-2xs);
|
|
89
90
|
padding: var(--space-2xs) var(--space-sm);
|
|
90
|
-
border:
|
|
91
|
+
border: var(--border-width) solid var(--color-border);
|
|
91
92
|
border-radius: var(--radius-md);
|
|
92
93
|
background: var(--color-surface);
|
|
93
94
|
color: var(--color-text);
|
|
@@ -103,37 +104,50 @@
|
|
|
103
104
|
|
|
104
105
|
.service-nav-list {
|
|
105
106
|
display: flex;
|
|
106
|
-
align-items:
|
|
107
|
-
|
|
107
|
+
align-items: stretch;
|
|
108
|
+
height: 100%;
|
|
108
109
|
margin: 0;
|
|
109
110
|
padding: 0;
|
|
110
111
|
list-style: none;
|
|
111
112
|
}
|
|
113
|
+
.service-nav-item {
|
|
114
|
+
display: flex;
|
|
115
|
+
}
|
|
112
116
|
|
|
117
|
+
/* Full-height links → the active underline bar sits flush on the band's
|
|
118
|
+
bottom border. Horizontal padding only; no radius (a bar, not a pill). */
|
|
113
119
|
.service-nav-link {
|
|
114
|
-
display:
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
height: 100%;
|
|
123
|
+
padding-inline: var(--space-md);
|
|
117
124
|
color: var(--color-text-secondary);
|
|
118
125
|
text-decoration: none;
|
|
119
126
|
font-family: var(--type-label-font);
|
|
120
|
-
font-size: var(--
|
|
127
|
+
font-size: var(--nav-service-link-size);
|
|
128
|
+
font-weight: var(--raw-font-weight-medium);
|
|
129
|
+
transition: color var(--duration-fast) var(--easing-default);
|
|
121
130
|
}
|
|
122
131
|
.service-nav-link:hover {
|
|
123
132
|
color: var(--color-text);
|
|
124
|
-
background: var(--color-surface-tertiary);
|
|
125
133
|
}
|
|
126
|
-
/* Current page: emphasized text + an accent underline bar (GOV.UK pattern).
|
|
134
|
+
/* Current page: emphasized text + an accent underline bar (GOV.UK pattern).
|
|
135
|
+
Thickness via the shared --nav-service-underline token (also used by
|
|
136
|
+
SectionNavigation) so the two nav tiers stay visually identical. */
|
|
127
137
|
.service-nav-link[aria-current="page"] {
|
|
128
138
|
color: var(--color-text);
|
|
129
139
|
font-weight: var(--raw-font-weight-semibold);
|
|
130
|
-
box-shadow: inset 0 -
|
|
140
|
+
box-shadow: inset 0 calc(-1 * var(--nav-service-underline)) 0 0
|
|
141
|
+
var(--color-accent);
|
|
131
142
|
}
|
|
132
143
|
|
|
133
|
-
/* Mobile: button shows, list collapses
|
|
144
|
+
/* Mobile: button shows, list collapses into a vertical dropdown. The links
|
|
145
|
+
revert to a padded list style (no full-height flush bar — that treatment is
|
|
146
|
+
for the horizontal row only); the current item reads via a subtle fill. */
|
|
134
147
|
@media (max-width: 47.99rem) {
|
|
135
148
|
.service-nav {
|
|
136
149
|
position: relative;
|
|
150
|
+
height: auto;
|
|
137
151
|
}
|
|
138
152
|
.service-nav-toggle {
|
|
139
153
|
display: inline-flex;
|
|
@@ -143,12 +157,13 @@
|
|
|
143
157
|
position: absolute;
|
|
144
158
|
top: calc(100% + var(--space-2xs));
|
|
145
159
|
inset-inline-end: 0;
|
|
160
|
+
height: auto;
|
|
146
161
|
flex-direction: column;
|
|
147
162
|
align-items: stretch;
|
|
148
|
-
min-width:
|
|
163
|
+
min-width: var(--nav-service-dropdown-min-width);
|
|
149
164
|
padding: var(--space-2xs);
|
|
150
165
|
background: var(--color-surface);
|
|
151
|
-
border:
|
|
166
|
+
border: var(--border-width) solid var(--color-border);
|
|
152
167
|
border-radius: var(--radius-md);
|
|
153
168
|
box-shadow: var(--shadow-md);
|
|
154
169
|
z-index: 50;
|
|
@@ -156,5 +171,14 @@
|
|
|
156
171
|
.service-nav-list.open {
|
|
157
172
|
display: flex;
|
|
158
173
|
}
|
|
174
|
+
.service-nav-link {
|
|
175
|
+
height: auto;
|
|
176
|
+
padding: var(--space-2xs) var(--space-sm);
|
|
177
|
+
border-radius: var(--radius-md);
|
|
178
|
+
}
|
|
179
|
+
.service-nav-link[aria-current="page"] {
|
|
180
|
+
background: var(--color-surface-tertiary);
|
|
181
|
+
box-shadow: none;
|
|
182
|
+
}
|
|
159
183
|
}
|
|
160
184
|
</style>
|
|
@@ -2,17 +2,36 @@
|
|
|
2
2
|
@component SiteHeader
|
|
3
3
|
|
|
4
4
|
Public site banner landmark (`<header role="banner">`). Locked accessibility
|
|
5
|
-
chrome for the citizen portal: a brand area, a primary navigation slot,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
chrome for the citizen portal: a brand area, a primary navigation slot, an
|
|
6
|
+
actions slot (locale switch, sign-in), and — for two-level information
|
|
7
|
+
architectures — an optional contextual `subnav` band and a single unified
|
|
8
|
+
mobile disclosure. Structure and landmarks are fixed (conformance-checkable);
|
|
9
|
+
only brand tokens vary per tenant via `data-theme`.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Two tiers, one banner:
|
|
12
|
+
- `nav` — the PRIMARY row (sections), a `ServiceNavigation`.
|
|
13
|
+
- `subnav` — an OPTIONAL second band directly beneath the primary row,
|
|
14
|
+
a `SectionNavigation` showing the active section's pages. It
|
|
15
|
+
lives inside this same sticky banner so it scrolls/sticks as one
|
|
16
|
+
unit and never reflows the primary row sideways.
|
|
17
|
+
|
|
18
|
+
Unified mobile menu (fixes the "two hamburgers" problem): when a `menu` snippet
|
|
19
|
+
is provided, this header renders ONE toggle below `--breakpoint` and hides the
|
|
20
|
+
inline `nav` + `subnav`; both tiers collapse into a single hierarchical panel
|
|
21
|
+
(the `menu` snippet) instead of each nav growing its own toggle. Consumers
|
|
22
|
+
without a `menu` snippet keep the previous behaviour unchanged (backward
|
|
23
|
+
compatible) — the inline `nav`'s own responsive collapse still applies.
|
|
24
|
+
|
|
25
|
+
Icon-agnostic, per DS convention — pass the toggle/close icons as snippets;
|
|
26
|
+
minimal glyphs are used as defaults.
|
|
10
27
|
|
|
11
28
|
@example
|
|
12
29
|
<SiteHeader>
|
|
13
30
|
{#snippet brand()}<a href="/"><Logo /> Valongo</a>{/snippet}
|
|
14
|
-
{#snippet nav()}<ServiceNavigation items={
|
|
31
|
+
{#snippet nav()}<ServiceNavigation items={sections} />{/snippet}
|
|
32
|
+
{#snippet subnav()}<SectionNavigation label={sectionName} items={pages} />{/snippet}
|
|
15
33
|
{#snippet actions()}<LocaleSwitch /> <Button>Sign in</Button>{/snippet}
|
|
34
|
+
{#snippet menu()}…hierarchical sections → pages tree…{/snippet}
|
|
16
35
|
</SiteHeader>
|
|
17
36
|
-->
|
|
18
37
|
<script>
|
|
@@ -25,13 +44,45 @@
|
|
|
25
44
|
brand = undefined,
|
|
26
45
|
/** @type {import('svelte').Snippet | undefined} Primary navigation links. */
|
|
27
46
|
nav = undefined,
|
|
47
|
+
/** @type {import('svelte').Snippet | undefined} Contextual second band (compose `SectionNavigation`). */
|
|
48
|
+
subnav = undefined,
|
|
28
49
|
/** @type {import('svelte').Snippet | undefined} Trailing actions (locale, auth). */
|
|
29
50
|
actions = undefined,
|
|
51
|
+
/** @type {import('svelte').Snippet | undefined} Hierarchical menu content for the unified mobile disclosure. */
|
|
52
|
+
menu = undefined,
|
|
53
|
+
/** @type {string} Visible/aria label for the mobile toggle (localize it). */
|
|
54
|
+
menuLabel = "Menu",
|
|
55
|
+
/** @type {string} aria-label for the mobile menu panel (localize it). */
|
|
56
|
+
menuPanelLabel = "Menu",
|
|
57
|
+
/** @type {string} aria-label for the close button (localize it). */
|
|
58
|
+
closeLabel = "Close",
|
|
59
|
+
/** @type {string} id linking the toggle to the panel (unique per page). */
|
|
60
|
+
menuId = "site-header-menu",
|
|
61
|
+
/** @type {import('svelte').Snippet | undefined} Icon for the mobile toggle. */
|
|
62
|
+
menuIcon = undefined,
|
|
63
|
+
/** @type {import('svelte').Snippet | undefined} Icon for the panel close button. */
|
|
64
|
+
closeIcon = undefined,
|
|
30
65
|
...rest
|
|
31
66
|
} = $props();
|
|
67
|
+
|
|
68
|
+
let open = $state(false);
|
|
69
|
+
const hasMenu = $derived(!!menu);
|
|
70
|
+
|
|
71
|
+
// Lock body scroll while the mobile panel is open (no-op during SSR).
|
|
72
|
+
$effect(() => {
|
|
73
|
+
if (typeof document === "undefined") return;
|
|
74
|
+
document.body.style.overflow = open ? "hidden" : "";
|
|
75
|
+
return () => {
|
|
76
|
+
document.body.style.overflow = "";
|
|
77
|
+
};
|
|
78
|
+
});
|
|
32
79
|
</script>
|
|
33
80
|
|
|
34
|
-
<header
|
|
81
|
+
<header
|
|
82
|
+
class="site-header {className}"
|
|
83
|
+
class:has-mobile-menu={hasMenu}
|
|
84
|
+
{...rest}
|
|
85
|
+
>
|
|
35
86
|
<div class="site-header-inner">
|
|
36
87
|
{#if brand}
|
|
37
88
|
<div class="site-header-brand">{@render brand()}</div>
|
|
@@ -42,26 +93,106 @@
|
|
|
42
93
|
{#if actions}
|
|
43
94
|
<div class="site-header-actions">{@render actions()}</div>
|
|
44
95
|
{/if}
|
|
96
|
+
{#if hasMenu}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="site-header-toggle"
|
|
100
|
+
aria-expanded={open}
|
|
101
|
+
aria-controls={menuId}
|
|
102
|
+
aria-haspopup="true"
|
|
103
|
+
onclick={() => (open = true)}
|
|
104
|
+
>
|
|
105
|
+
{#if menuIcon}{@render menuIcon()}{:else}
|
|
106
|
+
<svg
|
|
107
|
+
class="site-header-toggle-glyph"
|
|
108
|
+
viewBox="0 0 16 16"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
>
|
|
111
|
+
<path
|
|
112
|
+
d="M2 4h12M2 8h12M2 12h12"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
stroke-width="1.5"
|
|
115
|
+
stroke-linecap="round"
|
|
116
|
+
/>
|
|
117
|
+
</svg>
|
|
118
|
+
{/if}
|
|
119
|
+
<span class="site-header-toggle-label">{menuLabel}</span>
|
|
120
|
+
</button>
|
|
121
|
+
{/if}
|
|
45
122
|
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Contextual second band: inside the banner, beneath the primary row. -->
|
|
125
|
+
{#if subnav}{@render subnav()}{/if}
|
|
46
126
|
</header>
|
|
47
127
|
|
|
128
|
+
{#if hasMenu}
|
|
129
|
+
<!-- Single unified mobile disclosure: one panel hosting both nav tiers as a
|
|
130
|
+
hierarchy. Rendered outside the banner so it overlays the page (the
|
|
131
|
+
header stays sticky underneath). -->
|
|
132
|
+
<div
|
|
133
|
+
class="site-header-scrim"
|
|
134
|
+
class:open
|
|
135
|
+
onclick={() => (open = false)}
|
|
136
|
+
aria-hidden="true"
|
|
137
|
+
></div>
|
|
138
|
+
<div
|
|
139
|
+
id={menuId}
|
|
140
|
+
class="site-header-panel"
|
|
141
|
+
class:open
|
|
142
|
+
role="dialog"
|
|
143
|
+
aria-modal="true"
|
|
144
|
+
aria-label={menuPanelLabel}
|
|
145
|
+
>
|
|
146
|
+
<div class="site-header-panel-head">
|
|
147
|
+
<span class="site-header-panel-title">{menuPanelLabel}</span>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
class="site-header-close"
|
|
151
|
+
aria-label={closeLabel}
|
|
152
|
+
onclick={() => (open = false)}
|
|
153
|
+
>
|
|
154
|
+
{#if closeIcon}{@render closeIcon()}{:else}
|
|
155
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
156
|
+
<path
|
|
157
|
+
d="M4 4l8 8M12 4l-8 8"
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
stroke-width="1.5"
|
|
160
|
+
stroke-linecap="round"
|
|
161
|
+
/>
|
|
162
|
+
</svg>
|
|
163
|
+
{/if}
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="site-header-panel-body">{@render menu()}</div>
|
|
167
|
+
</div>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
170
|
+
<svelte:window
|
|
171
|
+
onkeydown={(e) => {
|
|
172
|
+
if (e.key === "Escape") open = false;
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
|
|
48
176
|
<style>
|
|
49
177
|
.site-header {
|
|
50
178
|
position: sticky;
|
|
51
179
|
top: 0;
|
|
52
180
|
z-index: 40;
|
|
53
181
|
background: var(--color-surface);
|
|
54
|
-
border-bottom:
|
|
182
|
+
border-bottom: var(--border-width) solid var(--color-border);
|
|
55
183
|
}
|
|
56
184
|
|
|
185
|
+
/* Fixed gridded height + stretch so the primary nav links fill the band and
|
|
186
|
+
their active underline bar sits flush on the header's bottom border. */
|
|
57
187
|
.site-header-inner {
|
|
58
188
|
display: flex;
|
|
59
|
-
align-items:
|
|
189
|
+
align-items: stretch;
|
|
60
190
|
gap: var(--space-lg);
|
|
61
191
|
width: 100%;
|
|
62
192
|
max-width: var(--content-width-wide);
|
|
63
193
|
margin-inline: auto;
|
|
64
|
-
|
|
194
|
+
height: var(--nav-service-height);
|
|
195
|
+
padding-inline: var(--content-padding-x);
|
|
65
196
|
}
|
|
66
197
|
|
|
67
198
|
.site-header-brand {
|
|
@@ -74,10 +205,11 @@
|
|
|
74
205
|
}
|
|
75
206
|
|
|
76
207
|
/* The nav slot hosts a `ServiceNavigation` (data-driven, self-styled).
|
|
77
|
-
|
|
208
|
+
Stretch so its full-height links can fill the band; the header doesn't
|
|
209
|
+
style anchors itself — no descendant-anchor magic. */
|
|
78
210
|
.site-header-nav {
|
|
79
211
|
display: flex;
|
|
80
|
-
align-items:
|
|
212
|
+
align-items: stretch;
|
|
81
213
|
}
|
|
82
214
|
|
|
83
215
|
/* Push trailing actions to the far end regardless of nav presence. */
|
|
@@ -87,4 +219,121 @@
|
|
|
87
219
|
gap: var(--space-sm);
|
|
88
220
|
margin-inline-start: auto;
|
|
89
221
|
}
|
|
222
|
+
|
|
223
|
+
/* ─── Mobile toggle (only when a `menu` snippet is provided) ─── */
|
|
224
|
+
.site-header-toggle {
|
|
225
|
+
display: none;
|
|
226
|
+
align-self: center;
|
|
227
|
+
align-items: center;
|
|
228
|
+
gap: var(--space-2xs);
|
|
229
|
+
padding: var(--space-2xs) var(--space-sm);
|
|
230
|
+
border: var(--border-width) solid var(--color-border);
|
|
231
|
+
border-radius: var(--radius-md);
|
|
232
|
+
background: var(--color-surface);
|
|
233
|
+
color: var(--color-text);
|
|
234
|
+
font-family: var(--type-label-font);
|
|
235
|
+
font-size: var(--type-label-size);
|
|
236
|
+
font-weight: var(--type-label-weight);
|
|
237
|
+
cursor: pointer;
|
|
238
|
+
}
|
|
239
|
+
.site-header-toggle:focus-visible,
|
|
240
|
+
.site-header-close:focus-visible {
|
|
241
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
242
|
+
outline-offset: var(--focus-ring-offset);
|
|
243
|
+
}
|
|
244
|
+
.site-header-toggle-glyph {
|
|
245
|
+
width: var(--icon-size-sm);
|
|
246
|
+
height: var(--icon-size-sm);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ─── Mobile panel + scrim ─── */
|
|
250
|
+
.site-header-scrim {
|
|
251
|
+
position: fixed;
|
|
252
|
+
inset: 0;
|
|
253
|
+
z-index: 60;
|
|
254
|
+
background: var(--color-overlay);
|
|
255
|
+
opacity: 0;
|
|
256
|
+
visibility: hidden;
|
|
257
|
+
transition:
|
|
258
|
+
opacity var(--duration-fast) var(--easing-default),
|
|
259
|
+
visibility var(--duration-fast) var(--easing-default);
|
|
260
|
+
}
|
|
261
|
+
.site-header-scrim.open {
|
|
262
|
+
opacity: 1;
|
|
263
|
+
visibility: visible;
|
|
264
|
+
}
|
|
265
|
+
.site-header-panel {
|
|
266
|
+
position: fixed;
|
|
267
|
+
inset-block: 0;
|
|
268
|
+
inset-inline-end: 0;
|
|
269
|
+
z-index: 61;
|
|
270
|
+
width: var(--nav-menu-panel-width);
|
|
271
|
+
max-width: 100vw;
|
|
272
|
+
display: flex;
|
|
273
|
+
flex-direction: column;
|
|
274
|
+
overflow-y: auto;
|
|
275
|
+
background: var(--color-surface-raised);
|
|
276
|
+
border-inline-start: var(--border-width) solid var(--color-border);
|
|
277
|
+
box-shadow: var(--elevation-overlay);
|
|
278
|
+
transform: translateX(100%);
|
|
279
|
+
transition: transform var(--duration-normal) var(--easing-enter);
|
|
280
|
+
}
|
|
281
|
+
.site-header-panel.open {
|
|
282
|
+
transform: none;
|
|
283
|
+
}
|
|
284
|
+
.site-header-panel-head {
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
padding: var(--space-md);
|
|
289
|
+
border-bottom: var(--border-width) solid var(--color-border);
|
|
290
|
+
}
|
|
291
|
+
.site-header-panel-title {
|
|
292
|
+
font-family: var(--type-heading-sm-font);
|
|
293
|
+
font-size: var(--type-heading-sm-size);
|
|
294
|
+
font-weight: var(--type-heading-sm-weight);
|
|
295
|
+
color: var(--color-text);
|
|
296
|
+
}
|
|
297
|
+
.site-header-close {
|
|
298
|
+
display: inline-flex;
|
|
299
|
+
align-items: center;
|
|
300
|
+
justify-content: center;
|
|
301
|
+
padding: var(--space-xs);
|
|
302
|
+
border: var(--border-width) solid transparent;
|
|
303
|
+
border-radius: var(--radius-md);
|
|
304
|
+
background: none;
|
|
305
|
+
color: var(--color-text-secondary);
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
}
|
|
308
|
+
.site-header-close svg {
|
|
309
|
+
width: var(--icon-size-md);
|
|
310
|
+
height: var(--icon-size-md);
|
|
311
|
+
}
|
|
312
|
+
.site-header-close:hover {
|
|
313
|
+
color: var(--color-text);
|
|
314
|
+
}
|
|
315
|
+
.site-header-panel-body {
|
|
316
|
+
flex: 1 0 auto;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* Below the breakpoint, when a unified menu exists: hide both inline nav tiers
|
|
320
|
+
and reveal the single toggle. The actions cluster (locale/auth) stays in the
|
|
321
|
+
bar. Consumers WITHOUT a `menu` keep the inline nav + its own collapse.
|
|
322
|
+
(SectionNavigation already self-hides below the breakpoint.) */
|
|
323
|
+
@media (max-width: 47.99rem) {
|
|
324
|
+
.has-mobile-menu .site-header-nav {
|
|
325
|
+
display: none;
|
|
326
|
+
}
|
|
327
|
+
.has-mobile-menu .site-header-toggle {
|
|
328
|
+
display: inline-flex;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* Reduced-motion: drop the slide/fade, keep the state change instant. */
|
|
333
|
+
@media (prefers-reduced-motion: reduce) {
|
|
334
|
+
.site-header-scrim,
|
|
335
|
+
.site-header-panel {
|
|
336
|
+
transition: none;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
90
339
|
</style>
|
package/components/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
|
|
|
42
42
|
export { default as ContrastToggle } from "./ContrastToggle.svelte";
|
|
43
43
|
export { default as LinkHighlightToggle } from "./LinkHighlightToggle.svelte";
|
|
44
44
|
export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
|
|
45
|
+
export { default as SectionNavigation } from "./SectionNavigation.svelte";
|
|
45
46
|
export { default as Link } from "./Link.svelte";
|
|
46
47
|
export { default as Hero } from "./Hero.svelte";
|
|
47
48
|
export { default as ContentBlock } from "./ContentBlock.svelte";
|
package/package.json
CHANGED
package/tokens/components.css
CHANGED
|
@@ -200,6 +200,35 @@
|
|
|
200
200
|
--nav-bottom-item-font: var(--type-caption-font);
|
|
201
201
|
--nav-bottom-item-size: var(--type-caption-size);
|
|
202
202
|
|
|
203
|
+
/* ═══════════════════════════════════════════════
|
|
204
|
+
NAVIGATION — SERVICE & SECTION (PUBLIC PORTAL)
|
|
205
|
+
═══════════════════════════════════════════════ */
|
|
206
|
+
|
|
207
|
+
/* Active-item underline bar shared by ServiceNavigation (primary tier) and
|
|
208
|
+
SectionNavigation (contextual second band) so both tiers read as one
|
|
209
|
+
system — the single source of truth for the bar thickness. The links fill
|
|
210
|
+
their band's full height so the bar sits flush on the band's bottom border
|
|
211
|
+
(the GOV.UK / National Archives treatment), never floating mid-row. */
|
|
212
|
+
--nav-service-underline: 3px;
|
|
213
|
+
|
|
214
|
+
/* Gridded band heights (8px base) — the primary header band and the
|
|
215
|
+
contextual section band below it. */
|
|
216
|
+
--nav-service-height: 64px;
|
|
217
|
+
--nav-section-height: 48px;
|
|
218
|
+
|
|
219
|
+
/* Type step-down: the primary tier reads larger than the contextual tier so
|
|
220
|
+
the two-level hierarchy is legible at a glance. */
|
|
221
|
+
--nav-service-link-size: var(--type-body-size);
|
|
222
|
+
--nav-section-link-size: var(--type-body-sm-size);
|
|
223
|
+
|
|
224
|
+
/* Width of SiteHeader's unified mobile disclosure panel (the single drawer
|
|
225
|
+
that replaces the per-nav hamburgers). Mirrors the narrow content panel. */
|
|
226
|
+
--nav-menu-panel-width: var(--panel-width-narrow);
|
|
227
|
+
|
|
228
|
+
/* Min width of ServiceNavigation's own mobile dropdown (used when the nav is
|
|
229
|
+
rendered standalone, without SiteHeader's unified menu). */
|
|
230
|
+
--nav-service-dropdown-min-width: 12rem;
|
|
231
|
+
|
|
203
232
|
/* ═══════════════════════════════════════════════
|
|
204
233
|
DATA DISPLAY
|
|
205
234
|
═══════════════════════════════════════════════ */
|