@aiaiai-pt/design-system 0.42.0 → 0.44.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/ActionFormRenderer.svelte +101 -6
- package/components/ActionFormRenderer.svelte.d.ts +11 -0
- package/components/MegaMenu.svelte +247 -0
- package/components/MegaMenu.svelte.d.ts +49 -0
- package/components/Panel.svelte +66 -9
- package/components/Panel.svelte.d.ts +7 -0
- package/components/ServiceNavigation.svelte +5 -0
- package/components/index.d.ts +1 -0
- package/components/index.js +1 -0
- package/package.json +1 -1
- package/tokens/components.css +5 -0
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import Button from "./Button.svelte";
|
|
6
6
|
import Alert from "./Alert.svelte";
|
|
7
7
|
import CodeBlock from "./CodeBlock.svelte";
|
|
8
|
+
import Combobox from "./Combobox.svelte";
|
|
9
|
+
import DateTimePicker from "./DateTimePicker.svelte";
|
|
8
10
|
import Input from "./Input.svelte";
|
|
9
11
|
import MapPicker from "./MapPicker.svelte";
|
|
10
12
|
import FileUpload from "./FileUpload.svelte";
|
|
@@ -69,6 +71,17 @@
|
|
|
69
71
|
* the attachment_keys payload contract; the CONSUMER owns transport/auth
|
|
70
72
|
* (FileUpload philosophy). Omitted → file params render disabled. */
|
|
71
73
|
uploadFile?: (file: File) => Promise<{ key: string } | { error: string }>;
|
|
74
|
+
/** Lookup for `object_reference`-typed parameters (#629 PR1b): receives the
|
|
75
|
+
* parameter's `object_type` (the entity type code), the operator's query,
|
|
76
|
+
* and the parameter's optional `object_filter`; returns picker items. The
|
|
77
|
+
* renderer owns the Combobox UI + value plumbing; the CONSUMER owns
|
|
78
|
+
* transport/auth/tenancy (the uploadFile seam pattern — the DS stays
|
|
79
|
+
* presentation-only). Omitted → object_reference params render disabled. */
|
|
80
|
+
searchEntities?: (
|
|
81
|
+
typeCode: string,
|
|
82
|
+
query: string,
|
|
83
|
+
filter?: Record<string, string>,
|
|
84
|
+
) => Promise<SelectOption[]>;
|
|
72
85
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
73
86
|
* in submit modes. */
|
|
74
87
|
captcha?: Snippet;
|
|
@@ -112,6 +125,7 @@
|
|
|
112
125
|
schema = null,
|
|
113
126
|
onApply = undefined,
|
|
114
127
|
uploadFile = undefined,
|
|
128
|
+
searchEntities = undefined,
|
|
115
129
|
captcha = undefined,
|
|
116
130
|
submitLabel = "Submit",
|
|
117
131
|
boundary = undefined,
|
|
@@ -264,14 +278,20 @@
|
|
|
264
278
|
}
|
|
265
279
|
|
|
266
280
|
function sectionName(parameter: Entity): string {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
281
|
+
// Mirrors the BFF `_parameter_section` precedence (action_schema.py):
|
|
282
|
+
// ui_schema.section_label → ui_schema.section → visibility.section_label
|
|
283
|
+
// → visibility.section → "Details". `||` (not `??`) to match Python `or`
|
|
284
|
+
// falsy-chaining — an empty string falls through to the next rung.
|
|
272
285
|
const uiSchema = parameter.ui_schema;
|
|
273
286
|
if (uiSchema && typeof uiSchema === "object" && !Array.isArray(uiSchema)) {
|
|
274
|
-
const
|
|
287
|
+
const row = uiSchema as Record<string, unknown>;
|
|
288
|
+
const section = row.section_label || row.section;
|
|
289
|
+
if (section) return String(section);
|
|
290
|
+
}
|
|
291
|
+
const visibility = parameter.visibility;
|
|
292
|
+
if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
|
|
293
|
+
const row = visibility as Record<string, unknown>;
|
|
294
|
+
const section = row.section_label || row.section;
|
|
275
295
|
if (section) return String(section);
|
|
276
296
|
}
|
|
277
297
|
return "Details";
|
|
@@ -400,6 +420,54 @@
|
|
|
400
420
|
}
|
|
401
421
|
}
|
|
402
422
|
|
|
423
|
+
// `object_reference` picker state (#629 PR1b): per-parameter items + loading
|
|
424
|
+
// for the lazy-search Combobox. A per-key generation counter drops stale
|
|
425
|
+
// responses (the host's validateGen pattern) so a slow early query can't
|
|
426
|
+
// clobber a fast later one.
|
|
427
|
+
let referenceItems = $state<Record<string, SelectOption[]>>({});
|
|
428
|
+
let referenceLoading = $state<Record<string, boolean>>({});
|
|
429
|
+
const referenceSearchGen: Record<string, number> = {};
|
|
430
|
+
|
|
431
|
+
function objectFilter(parameter: Entity): Record<string, string> | undefined {
|
|
432
|
+
const filter = parameter.object_filter;
|
|
433
|
+
return filter && typeof filter === "object" && !Array.isArray(filter)
|
|
434
|
+
? (filter as Record<string, string>)
|
|
435
|
+
: undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function handleReferenceSearch(parameter: Entity, query: string) {
|
|
439
|
+
if (!searchEntities) return;
|
|
440
|
+
const key = parameterKey(parameter);
|
|
441
|
+
const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
|
|
442
|
+
referenceLoading = { ...referenceLoading, [key]: true };
|
|
443
|
+
try {
|
|
444
|
+
const items = await searchEntities(
|
|
445
|
+
String(parameter.object_type ?? ""),
|
|
446
|
+
query,
|
|
447
|
+
objectFilter(parameter),
|
|
448
|
+
);
|
|
449
|
+
if (referenceSearchGen[key] !== gen) return;
|
|
450
|
+
referenceItems = { ...referenceItems, [key]: Array.isArray(items) ? items : [] };
|
|
451
|
+
} catch {
|
|
452
|
+
if (referenceSearchGen[key] === gen) referenceItems = { ...referenceItems, [key]: [] };
|
|
453
|
+
} finally {
|
|
454
|
+
if (referenceSearchGen[key] === gen)
|
|
455
|
+
referenceLoading = { ...referenceLoading, [key]: false };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
|
|
460
|
+
// datetime wire vocabulary — `$now` resolves to one) so the payload stays
|
|
461
|
+
// plain JSON; the DateTimePicker speaks Date on its prop edge.
|
|
462
|
+
function datetimeValue(value: unknown): Date | null {
|
|
463
|
+
if (value instanceof Date) return value;
|
|
464
|
+
if (typeof value === "string" && value) {
|
|
465
|
+
const parsed = new Date(value);
|
|
466
|
+
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
403
471
|
function removeFile(key: string, storageKey: string) {
|
|
404
472
|
fileUploads = {
|
|
405
473
|
...fileUploads,
|
|
@@ -490,6 +558,33 @@
|
|
|
490
558
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
491
559
|
aria-required={ariaRequired}
|
|
492
560
|
/>
|
|
561
|
+
{:else if type === "datetime"}
|
|
562
|
+
<!-- `datetime` parameter (#629 PR1b): the DS date+time picker. The value
|
|
563
|
+
bag carries an ISO 8601 string (see datetimeValue). `datetime` is the
|
|
564
|
+
platform's one wire token today (sheets + ActionParam docs). -->
|
|
565
|
+
<DateTimePicker
|
|
566
|
+
label={String(parameter.label ?? key)}
|
|
567
|
+
value={datetimeValue(values[key])}
|
|
568
|
+
readonly={!editable}
|
|
569
|
+
onchange={(date: Date) => setValue(key, date.toISOString())}
|
|
570
|
+
/>
|
|
571
|
+
{:else if type === "object_reference"}
|
|
572
|
+
<!-- `object_reference` parameter (#629 PR1b): lazy-search Combobox over
|
|
573
|
+
the entity type named by `object_type`; `object_filter` rides the
|
|
574
|
+
injected searchEntities seam. No seam → disabled (the file-param
|
|
575
|
+
convention for a missing consumer transport). -->
|
|
576
|
+
<Combobox
|
|
577
|
+
label={String(parameter.label ?? key)}
|
|
578
|
+
items={referenceItems[key] ?? []}
|
|
579
|
+
value={String(values[key] ?? "")}
|
|
580
|
+
placeholder={typeof parameter.object_type === "string" && parameter.object_type
|
|
581
|
+
? `Search ${parameter.object_type}...`
|
|
582
|
+
: "Search..."}
|
|
583
|
+
disabled={!searchEntities || !editable}
|
|
584
|
+
loading={referenceLoading[key] ?? false}
|
|
585
|
+
onsearch={(query: string) => handleReferenceSearch(parameter, query)}
|
|
586
|
+
onchange={(value: string) => setValue(key, value)}
|
|
587
|
+
/>
|
|
493
588
|
{:else if type === "file"}
|
|
494
589
|
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
495
590
|
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
@@ -8,6 +8,10 @@ import { type RendererMode } from "./action-form-renderer-payload";
|
|
|
8
8
|
* compatible.
|
|
9
9
|
*/
|
|
10
10
|
type Entity = Record<string, unknown>;
|
|
11
|
+
type SelectOption = {
|
|
12
|
+
value: string;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
11
15
|
type ActionSchema = {
|
|
12
16
|
found?: boolean;
|
|
13
17
|
schema_version?: string;
|
|
@@ -54,6 +58,13 @@ interface Props {
|
|
|
54
58
|
} | {
|
|
55
59
|
error: string;
|
|
56
60
|
}>;
|
|
61
|
+
/** Lookup for `object_reference`-typed parameters (#629 PR1b): receives the
|
|
62
|
+
* parameter's `object_type` (the entity type code), the operator's query,
|
|
63
|
+
* and the parameter's optional `object_filter`; returns picker items. The
|
|
64
|
+
* renderer owns the Combobox UI + value plumbing; the CONSUMER owns
|
|
65
|
+
* transport/auth/tenancy (the uploadFile seam pattern — the DS stays
|
|
66
|
+
* presentation-only). Omitted → object_reference params render disabled. */
|
|
67
|
+
searchEntities?: (typeCode: string, query: string, filter?: Record<string, string>) => Promise<SelectOption[]>;
|
|
57
68
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
58
69
|
* in submit modes. */
|
|
59
70
|
captcha?: Snippet;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component MegaMenu
|
|
3
|
+
|
|
4
|
+
Category mega-menu for many-section portals (#507): a single "Services ▾"
|
|
5
|
+
disclosure in the primary band that opens a full-width panel of category
|
|
6
|
+
columns, so ~20 verticals collapse into one trigger instead of overflowing
|
|
7
|
+
the bar. Modelled on the GOV.UK "Menu" / Designers Italia mega-nav pattern.
|
|
8
|
+
|
|
9
|
+
Semantics: a disclosure (`button[aria-expanded][aria-controls]` + panel),
|
|
10
|
+
NOT a `menu` role — the panel holds plain links in labelled groups, which
|
|
11
|
+
screen readers navigate as ordinary lists. Escape closes and restores focus
|
|
12
|
+
to the trigger; clicking outside or navigating closes. The trigger reads as
|
|
13
|
+
current (underline bar) when any contained link is current.
|
|
14
|
+
|
|
15
|
+
Desktop-only affordance: below `--breakpoint` it renders nothing — small
|
|
16
|
+
screens keep their native menu (SiteHeader's unified disclosure), which the
|
|
17
|
+
consumer feeds the same category groups.
|
|
18
|
+
|
|
19
|
+
Layout note: the panel is absolutely positioned against the NEAREST
|
|
20
|
+
POSITIONED ANCESTOR — inside `SiteHeader` (position: sticky) it spans the
|
|
21
|
+
full header band, which is the intended composition:
|
|
22
|
+
|
|
23
|
+
@example
|
|
24
|
+
<SiteHeader>
|
|
25
|
+
{#snippet nav()}
|
|
26
|
+
<ServiceNavigation label="Primary" items={primary} />
|
|
27
|
+
<MegaMenu label="Serviços" categories={buckets} />
|
|
28
|
+
{/snippet}
|
|
29
|
+
</SiteHeader>
|
|
30
|
+
-->
|
|
31
|
+
<script>
|
|
32
|
+
let {
|
|
33
|
+
/** @type {string} Visible trigger label (localize it). */
|
|
34
|
+
label = "Services",
|
|
35
|
+
/** @type {Array<{ label: string, items: Array<{ href: string, label: string, current?: boolean }> }>} */
|
|
36
|
+
categories = [],
|
|
37
|
+
/** @type {string} id linking the trigger to the panel (unique per page). */
|
|
38
|
+
menuId = "mega-menu",
|
|
39
|
+
/** @type {string} */
|
|
40
|
+
class: className = "",
|
|
41
|
+
/** @type {import('svelte').Snippet | undefined} Trigger icon (chevron). */
|
|
42
|
+
icon = undefined,
|
|
43
|
+
...rest
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
let open = $state(false);
|
|
47
|
+
/** @type {HTMLElement | undefined} */
|
|
48
|
+
let root = $state(undefined);
|
|
49
|
+
/** @type {HTMLButtonElement | undefined} */
|
|
50
|
+
let trigger = $state(undefined);
|
|
51
|
+
|
|
52
|
+
const hasCurrent = $derived(
|
|
53
|
+
categories.some((c) => (c.items ?? []).some((i) => i.current)),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
function onDocumentClick(event) {
|
|
57
|
+
if (open && root && !root.contains(event.target)) open = false;
|
|
58
|
+
}
|
|
59
|
+
function onDocumentKeydown(event) {
|
|
60
|
+
if (event.key === "Escape" && open) {
|
|
61
|
+
open = false;
|
|
62
|
+
trigger?.focus();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
{#if categories.length > 0}
|
|
68
|
+
<div class="mega-menu {className}" bind:this={root} {...rest}>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="mega-menu-trigger"
|
|
72
|
+
class:current={hasCurrent}
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
aria-controls={menuId}
|
|
75
|
+
bind:this={trigger}
|
|
76
|
+
onclick={() => (open = !open)}
|
|
77
|
+
>
|
|
78
|
+
{label}
|
|
79
|
+
{#if icon}{@render icon()}{:else}
|
|
80
|
+
<svg
|
|
81
|
+
class="mega-menu-chevron"
|
|
82
|
+
class:open
|
|
83
|
+
viewBox="0 0 16 16"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
>
|
|
86
|
+
<path
|
|
87
|
+
d="M4 6l4 4 4-4"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-width="1.5"
|
|
91
|
+
stroke-linecap="round"
|
|
92
|
+
stroke-linejoin="round"
|
|
93
|
+
/>
|
|
94
|
+
</svg>
|
|
95
|
+
{/if}
|
|
96
|
+
</button>
|
|
97
|
+
|
|
98
|
+
{#if open}
|
|
99
|
+
<div id={menuId} class="mega-menu-panel">
|
|
100
|
+
{#each categories as category (category.label)}
|
|
101
|
+
<section class="mega-menu-category" aria-label={category.label}>
|
|
102
|
+
<h3 class="mega-menu-heading">{category.label}</h3>
|
|
103
|
+
<ul class="mega-menu-list">
|
|
104
|
+
{#each category.items ?? [] as item (item.href)}
|
|
105
|
+
<li class="mega-menu-item">
|
|
106
|
+
<a
|
|
107
|
+
href={item.href}
|
|
108
|
+
class="mega-menu-link"
|
|
109
|
+
aria-current={item.current ? "page" : undefined}
|
|
110
|
+
onclick={() => (open = false)}
|
|
111
|
+
>
|
|
112
|
+
{item.label}
|
|
113
|
+
</a>
|
|
114
|
+
</li>
|
|
115
|
+
{/each}
|
|
116
|
+
</ul>
|
|
117
|
+
</section>
|
|
118
|
+
{/each}
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
<svelte:document onclick={onDocumentClick} onkeydown={onDocumentKeydown} />
|
|
125
|
+
|
|
126
|
+
<style>
|
|
127
|
+
.mega-menu {
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: stretch;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* The trigger reads as a peer of ServiceNavigation's links: same type ramp,
|
|
133
|
+
full band height, and the same accent underline bar when a contained link
|
|
134
|
+
is the current page. */
|
|
135
|
+
.mega-menu-trigger {
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: var(--space-2xs);
|
|
139
|
+
height: 100%;
|
|
140
|
+
padding-inline: var(--space-md);
|
|
141
|
+
border: none;
|
|
142
|
+
background: none;
|
|
143
|
+
color: var(--color-text-secondary);
|
|
144
|
+
font-family: var(--type-label-font);
|
|
145
|
+
font-size: var(--nav-service-link-size);
|
|
146
|
+
font-weight: var(--raw-font-weight-medium);
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
transition: color var(--duration-fast) var(--easing-default);
|
|
149
|
+
}
|
|
150
|
+
.mega-menu-trigger:hover,
|
|
151
|
+
.mega-menu-trigger[aria-expanded="true"] {
|
|
152
|
+
color: var(--color-text);
|
|
153
|
+
}
|
|
154
|
+
.mega-menu-trigger:focus-visible {
|
|
155
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
156
|
+
outline-offset: var(--focus-ring-offset);
|
|
157
|
+
}
|
|
158
|
+
.mega-menu-trigger.current {
|
|
159
|
+
color: var(--color-text);
|
|
160
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
161
|
+
box-shadow: inset 0 calc(-1 * var(--nav-service-underline)) 0 0
|
|
162
|
+
var(--color-accent);
|
|
163
|
+
}
|
|
164
|
+
.mega-menu-chevron {
|
|
165
|
+
width: var(--icon-size-sm);
|
|
166
|
+
height: var(--icon-size-sm);
|
|
167
|
+
transition: transform var(--duration-fast) var(--easing-default);
|
|
168
|
+
}
|
|
169
|
+
.mega-menu-chevron.open {
|
|
170
|
+
transform: rotate(180deg);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* Full-width panel anchored under the band (the nearest positioned ancestor
|
|
174
|
+
— SiteHeader's sticky banner). Category columns auto-flow; the panel
|
|
175
|
+
scrolls internally rather than covering the page (#507 overflow fix). */
|
|
176
|
+
.mega-menu-panel {
|
|
177
|
+
position: absolute;
|
|
178
|
+
top: 100%;
|
|
179
|
+
inset-inline: 0;
|
|
180
|
+
z-index: 50;
|
|
181
|
+
display: grid;
|
|
182
|
+
grid-template-columns: repeat(
|
|
183
|
+
auto-fill,
|
|
184
|
+
minmax(var(--nav-mega-column-min-width), 1fr)
|
|
185
|
+
);
|
|
186
|
+
gap: var(--space-lg);
|
|
187
|
+
padding: var(--space-lg) var(--content-padding-x);
|
|
188
|
+
max-height: var(--nav-mega-panel-max-height);
|
|
189
|
+
overflow-y: auto;
|
|
190
|
+
background: var(--color-surface-raised);
|
|
191
|
+
border-top: var(--border-width) solid var(--color-border);
|
|
192
|
+
border-bottom: var(--border-width) solid var(--color-border);
|
|
193
|
+
box-shadow: var(--shadow-md);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.mega-menu-heading {
|
|
197
|
+
margin: 0 0 var(--space-2xs);
|
|
198
|
+
font-family: var(--type-label-font);
|
|
199
|
+
font-size: var(--type-body-sm-size);
|
|
200
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
letter-spacing: 0.04em;
|
|
203
|
+
color: var(--color-text-secondary);
|
|
204
|
+
}
|
|
205
|
+
.mega-menu-list {
|
|
206
|
+
margin: 0;
|
|
207
|
+
padding: 0;
|
|
208
|
+
list-style: none;
|
|
209
|
+
}
|
|
210
|
+
.mega-menu-item {
|
|
211
|
+
display: flex;
|
|
212
|
+
}
|
|
213
|
+
.mega-menu-link {
|
|
214
|
+
flex: 1;
|
|
215
|
+
padding: var(--space-2xs) var(--space-2xs);
|
|
216
|
+
border-radius: var(--radius-md);
|
|
217
|
+
color: var(--color-text);
|
|
218
|
+
text-decoration: none;
|
|
219
|
+
font-family: var(--type-label-font);
|
|
220
|
+
font-size: var(--type-body-sm-size);
|
|
221
|
+
}
|
|
222
|
+
.mega-menu-link:hover {
|
|
223
|
+
background: var(--color-surface-tertiary);
|
|
224
|
+
}
|
|
225
|
+
.mega-menu-link:focus-visible {
|
|
226
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
227
|
+
outline-offset: var(--focus-ring-offset);
|
|
228
|
+
}
|
|
229
|
+
.mega-menu-link[aria-current="page"] {
|
|
230
|
+
background: var(--color-surface-tertiary);
|
|
231
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Desktop-only: small screens use the consumer's native menu (SiteHeader's
|
|
235
|
+
unified disclosure), grouped by the same categories. */
|
|
236
|
+
@media (max-width: 47.99rem) {
|
|
237
|
+
.mega-menu {
|
|
238
|
+
display: none;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@media (prefers-reduced-motion: reduce) {
|
|
243
|
+
.mega-menu-chevron {
|
|
244
|
+
transition: none;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export default MegaMenu;
|
|
2
|
+
type MegaMenu = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* MegaMenu
|
|
8
|
+
*
|
|
9
|
+
* Category mega-menu for many-section portals (#507): a single "Services ▾"
|
|
10
|
+
* disclosure in the primary band that opens a full-width panel of category
|
|
11
|
+
* columns, so ~20 verticals collapse into one trigger instead of overflowing
|
|
12
|
+
* the bar. Modelled on the GOV.UK "Menu" / Designers Italia mega-nav pattern.
|
|
13
|
+
*
|
|
14
|
+
* Semantics: a disclosure (`button[aria-expanded][aria-controls]` + panel),
|
|
15
|
+
* NOT a `menu` role — the panel holds plain links in labelled groups, which
|
|
16
|
+
* screen readers navigate as ordinary lists. Escape closes and restores focus
|
|
17
|
+
* to the trigger; clicking outside or navigating closes. The trigger reads as
|
|
18
|
+
* current (underline bar) when any contained link is current.
|
|
19
|
+
*
|
|
20
|
+
* Desktop-only affordance: below `--breakpoint` it renders nothing — small
|
|
21
|
+
* screens keep their native menu (SiteHeader's unified disclosure), which the
|
|
22
|
+
* consumer feeds the same category groups.
|
|
23
|
+
*
|
|
24
|
+
* Layout note: the panel is absolutely positioned against the NEAREST
|
|
25
|
+
* POSITIONED ANCESTOR — inside `SiteHeader` (position: sticky) it spans the
|
|
26
|
+
* full header band, which is the intended composition:
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <SiteHeader>
|
|
30
|
+
* {#snippet nav()}
|
|
31
|
+
* <ServiceNavigation label="Primary" items={primary} />
|
|
32
|
+
* <MegaMenu label="Serviços" categories={buckets} />
|
|
33
|
+
* {/snippet}
|
|
34
|
+
* </SiteHeader>
|
|
35
|
+
*/
|
|
36
|
+
declare const MegaMenu: import("svelte").Component<{
|
|
37
|
+
label?: string;
|
|
38
|
+
categories?: any[];
|
|
39
|
+
menuId?: string;
|
|
40
|
+
class?: string;
|
|
41
|
+
icon?: any;
|
|
42
|
+
} & Record<string, any>, {}, "">;
|
|
43
|
+
type $$ComponentProps = {
|
|
44
|
+
label?: string;
|
|
45
|
+
categories?: any[];
|
|
46
|
+
menuId?: string;
|
|
47
|
+
class?: string;
|
|
48
|
+
icon?: any;
|
|
49
|
+
} & Record<string, any>;
|
package/components/Panel.svelte
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
<Panel open persistent side="left" title="History">
|
|
19
19
|
Always-visible sidebar content
|
|
20
20
|
</Panel>
|
|
21
|
+
|
|
22
|
+
@example Modeless drawer (fixed slide-over, page behind stays interactive)
|
|
23
|
+
<Panel open modeless title="Execute action" onclose={() => showAction = false}>
|
|
24
|
+
Action form here — focus moves to the heading, Escape closes while inside
|
|
25
|
+
</Panel>
|
|
21
26
|
-->
|
|
22
27
|
<script module>
|
|
23
28
|
let _panelUid = 0;
|
|
@@ -40,6 +45,14 @@
|
|
|
40
45
|
side = 'right',
|
|
41
46
|
/** @type {boolean} When true, removes backdrop and focus trap. Panel becomes an inline layout element. */
|
|
42
47
|
persistent = false,
|
|
48
|
+
/** @type {boolean} Modeless drawer (#629): stays a FIXED slide-over like the
|
|
49
|
+
* default mode, but with no backdrop, no aria-modal, and no focus trap —
|
|
50
|
+
* the page behind remains browsable/interactive. On open the panel is a
|
|
51
|
+
* labelled region and focus MOVES to its heading (announced, not trapped);
|
|
52
|
+
* Escape closes only while focus is inside the panel; focus returns to the
|
|
53
|
+
* previously-focused element on close (the host owns any deep-link
|
|
54
|
+
* fallback). Ignored when `persistent` is set. */
|
|
55
|
+
modeless = false,
|
|
43
56
|
/** @type {boolean} When false, body doesn't scroll — children manage their own overflow. */
|
|
44
57
|
scrollBody = true,
|
|
45
58
|
/** @type {(() => void) | undefined} */
|
|
@@ -62,10 +75,17 @@
|
|
|
62
75
|
|
|
63
76
|
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
64
77
|
|
|
78
|
+
// Resolved mode: persistent wins over modeless (persistent consumers are
|
|
79
|
+
// inline layout elements — a fixed slide-over would be a breaking change).
|
|
80
|
+
const isPersistent = $derived(persistent);
|
|
81
|
+
const isModeless = $derived(modeless && !persistent);
|
|
82
|
+
const isDialog = $derived(!persistent && !modeless);
|
|
83
|
+
|
|
65
84
|
// Focus trap: save previous focus, trap Tab, restore on close
|
|
66
|
-
// Skipped in persistent
|
|
85
|
+
// Skipped in persistent and modeless modes (persistent is inline; modeless
|
|
86
|
+
// deliberately leaves the page behind reachable — focus moved, not trapped)
|
|
67
87
|
$effect(() => {
|
|
68
|
-
if (!open || !panelEl ||
|
|
88
|
+
if (!open || !panelEl || !isDialog) return;
|
|
69
89
|
|
|
70
90
|
const previouslyFocused = /** @type {HTMLElement | null} */ (document.activeElement);
|
|
71
91
|
|
|
@@ -107,11 +127,41 @@
|
|
|
107
127
|
function handleBackdropClick() {
|
|
108
128
|
onclose?.();
|
|
109
129
|
}
|
|
130
|
+
|
|
131
|
+
// Modeless drawer (#629): initial focus to the panel heading (announces the
|
|
132
|
+
// labelled region without trapping), Escape scoped to the PANEL element so
|
|
133
|
+
// it closes only while focus is inside, previous focus restored on close.
|
|
134
|
+
$effect(() => {
|
|
135
|
+
if (!open || !panelEl || !isModeless) return;
|
|
136
|
+
|
|
137
|
+
const previouslyFocused = /** @type {HTMLElement | null} */ (document.activeElement);
|
|
138
|
+
|
|
139
|
+
const heading = /** @type {HTMLElement | null} */ (
|
|
140
|
+
panelEl.querySelector('.panel-heading-focus')
|
|
141
|
+
);
|
|
142
|
+
heading?.focus();
|
|
143
|
+
|
|
144
|
+
/** @param {KeyboardEvent} e */
|
|
145
|
+
function handlePanelKeydown(e) {
|
|
146
|
+
if (e.key === 'Escape') onclose?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
panelEl.addEventListener('keydown', handlePanelKeydown);
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
panelEl?.removeEventListener('keydown', handlePanelKeydown);
|
|
153
|
+
// Deep-link entries have no meaningful previous focus (body) — the HOST
|
|
154
|
+
// owns that fallback (e.g. page H1); restoring a disconnected node is a
|
|
155
|
+
// no-op by contract.
|
|
156
|
+
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
|
157
|
+
};
|
|
158
|
+
});
|
|
110
159
|
</script>
|
|
111
160
|
|
|
112
161
|
{#if open}
|
|
113
|
-
<!-- Backdrop (
|
|
114
|
-
|
|
162
|
+
<!-- Backdrop (dialog mode only — persistent is inline, modeless leaves the
|
|
163
|
+
page behind visible and interactive) -->
|
|
164
|
+
{#if isDialog}
|
|
115
165
|
<div
|
|
116
166
|
class="panel-backdrop"
|
|
117
167
|
onclick={handleBackdropClick}
|
|
@@ -122,18 +172,25 @@
|
|
|
122
172
|
<!-- Panel -->
|
|
123
173
|
<aside
|
|
124
174
|
bind:this={panelEl}
|
|
125
|
-
class="panel panel-{width} panel-side-{side} {
|
|
126
|
-
role={
|
|
127
|
-
aria-modal={
|
|
175
|
+
class="panel panel-{width} panel-side-{side} {isPersistent ? 'panel-persistent' : ''} {className}"
|
|
176
|
+
role={isPersistent ? 'complementary' : isModeless ? 'region' : 'dialog'}
|
|
177
|
+
aria-modal={isDialog ? 'true' : undefined}
|
|
128
178
|
aria-label={!header ? title : undefined}
|
|
129
179
|
aria-labelledby={header ? headerId : undefined}
|
|
130
180
|
{...rest}
|
|
131
181
|
>
|
|
132
182
|
<div class="panel-header">
|
|
133
183
|
{#if header}
|
|
134
|
-
<div
|
|
184
|
+
<div
|
|
185
|
+
id={headerId}
|
|
186
|
+
class="panel-heading-focus"
|
|
187
|
+
tabindex={isModeless ? -1 : undefined}
|
|
188
|
+
>{@render header()}</div>
|
|
135
189
|
{:else if title}
|
|
136
|
-
<h2
|
|
190
|
+
<h2
|
|
191
|
+
class="panel-title panel-heading-focus"
|
|
192
|
+
tabindex={isModeless ? -1 : undefined}
|
|
193
|
+
>{title}</h2>
|
|
137
194
|
{/if}
|
|
138
195
|
{#if !persistent}
|
|
139
196
|
<button
|
|
@@ -23,6 +23,11 @@ type Panel = {
|
|
|
23
23
|
* <Panel open persistent side="left" title="History">
|
|
24
24
|
* Always-visible sidebar content
|
|
25
25
|
* </Panel>
|
|
26
|
+
*
|
|
27
|
+
* @example Modeless drawer (fixed slide-over, page behind stays interactive)
|
|
28
|
+
* <Panel open modeless title="Execute action" onclose={() => showAction = false}>
|
|
29
|
+
* Action form here — focus moves to the heading, Escape closes while inside
|
|
30
|
+
* </Panel>
|
|
26
31
|
*/
|
|
27
32
|
declare const Panel: import("svelte").Component<{
|
|
28
33
|
open?: boolean;
|
|
@@ -30,6 +35,7 @@ declare const Panel: import("svelte").Component<{
|
|
|
30
35
|
width?: string;
|
|
31
36
|
side?: string;
|
|
32
37
|
persistent?: boolean;
|
|
38
|
+
modeless?: boolean;
|
|
33
39
|
scrollBody?: boolean;
|
|
34
40
|
onclose: any;
|
|
35
41
|
class?: string;
|
|
@@ -43,6 +49,7 @@ type $$ComponentProps = {
|
|
|
43
49
|
width?: string;
|
|
44
50
|
side?: string;
|
|
45
51
|
persistent?: boolean;
|
|
52
|
+
modeless?: boolean;
|
|
46
53
|
scrollBody?: boolean;
|
|
47
54
|
onclose: any;
|
|
48
55
|
class?: string;
|
|
@@ -161,6 +161,11 @@
|
|
|
161
161
|
flex-direction: column;
|
|
162
162
|
align-items: stretch;
|
|
163
163
|
min-width: var(--nav-service-dropdown-min-width);
|
|
164
|
+
/* #507 overflow fix: many sections used to grow the dropdown past the
|
|
165
|
+
viewport and cover the page — cap it and scroll internally instead. */
|
|
166
|
+
max-width: calc(100vw - 2 * var(--content-padding-x));
|
|
167
|
+
max-height: min(70vh, 28rem);
|
|
168
|
+
overflow-y: auto;
|
|
164
169
|
padding: var(--space-2xs);
|
|
165
170
|
background: var(--color-surface);
|
|
166
171
|
border: var(--border-width) solid var(--color-border);
|
package/components/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { default as ListItem } from "./ListItem.svelte";
|
|
|
19
19
|
export { default as PageContainer } from "./PageContainer.svelte";
|
|
20
20
|
export { default as AppFrame } from "./AppFrame.svelte";
|
|
21
21
|
export { default as SiteHeader } from "./SiteHeader.svelte";
|
|
22
|
+
export { default as MegaMenu } from "./MegaMenu.svelte";
|
|
22
23
|
export { default as SiteFooter } from "./SiteFooter.svelte";
|
|
23
24
|
export { default as SkipLink } from "./SkipLink.svelte";
|
|
24
25
|
export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
|
package/components/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export { default as PageContainer } from "./PageContainer.svelte";
|
|
|
36
36
|
// Public site shell (locked a11y chrome — citizen portal, #7/#71)
|
|
37
37
|
export { default as AppFrame } from "./AppFrame.svelte";
|
|
38
38
|
export { default as SiteHeader } from "./SiteHeader.svelte";
|
|
39
|
+
export { default as MegaMenu } from "./MegaMenu.svelte";
|
|
39
40
|
export { default as SiteFooter } from "./SiteFooter.svelte";
|
|
40
41
|
export { default as SkipLink } from "./SkipLink.svelte";
|
|
41
42
|
export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
|
package/package.json
CHANGED
package/tokens/components.css
CHANGED
|
@@ -229,6 +229,11 @@
|
|
|
229
229
|
rendered standalone, without SiteHeader's unified menu). */
|
|
230
230
|
--nav-service-dropdown-min-width: 12rem;
|
|
231
231
|
|
|
232
|
+
/* MegaMenu (#507) — category column sizing + the panel's internal-scroll cap
|
|
233
|
+
so an 8-category panel scrolls inside itself instead of covering the page. */
|
|
234
|
+
--nav-mega-column-min-width: 13rem;
|
|
235
|
+
--nav-mega-panel-max-height: min(70vh, 32rem);
|
|
236
|
+
|
|
232
237
|
/* ═══════════════════════════════════════════════
|
|
233
238
|
DATA DISPLAY
|
|
234
239
|
═══════════════════════════════════════════════ */
|