@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.
@@ -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
- const visibility = parameter.visibility;
268
- if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
269
- const section = (visibility as Record<string, unknown>).section;
270
- if (section) return String(section);
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 section = (uiSchema as Record<string, unknown>).section;
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>;
@@ -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 mode (panel is inline, not modal)
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 || persistent) return;
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 (hidden in persistent mode) -->
114
- {#if !persistent}
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} {persistent ? 'panel-persistent' : ''} {className}"
126
- role={persistent ? 'complementary' : 'dialog'}
127
- aria-modal={persistent ? undefined : 'true'}
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 id={headerId}>{@render header()}</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 class="panel-title">{title}</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);
@@ -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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
  ═══════════════════════════════════════════════ */