@aiaiai-pt/design-system 0.23.0 → 0.25.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 +54 -14
- package/components/ActionFormRenderer.svelte.d.ts +16 -0
- package/components/CheckAnswers.svelte +197 -0
- package/components/CheckAnswers.svelte.d.ts +60 -0
- package/components/Confirmation.svelte +172 -0
- package/components/Confirmation.svelte.d.ts +53 -0
- package/components/ConsentManager.svelte +249 -0
- package/components/ConsentManager.svelte.d.ts +92 -0
- package/components/ServiceFlow.svelte +264 -0
- package/components/ServiceFlow.svelte.d.ts +83 -0
- package/components/index.js +10 -0
- package/package.json +1 -1
|
@@ -85,6 +85,22 @@
|
|
|
85
85
|
/** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
|
|
86
86
|
* out-of-bounds copy) — forwarded as MapPicker's `error`. */
|
|
87
87
|
geoError?: string;
|
|
88
|
+
/** Service-flow wizard (#105 P6): render ONLY the section whose
|
|
89
|
+
* name (its label, falling back to its key) matches — one screen per
|
|
90
|
+
* step. The value bag still spans ALL parameters (so answers persist
|
|
91
|
+
* across steps and the final payload is complete); only the DISPLAY is
|
|
92
|
+
* scoped. Null (default) renders every section, byte-identical to today. */
|
|
93
|
+
activeSectionKey?: string | null;
|
|
94
|
+
/** Service-flow wizard (#105 P6): suppress the renderer's own submit
|
|
95
|
+
* button / captcha / error — the wizard shell owns the nav and posts the
|
|
96
|
+
* payload once at the end. Default false (the standalone form keeps its
|
|
97
|
+
* button). */
|
|
98
|
+
hideSubmit?: boolean;
|
|
99
|
+
/** Service-flow wizard (#105 P6): fires with the freshly built payload
|
|
100
|
+
* whenever a value or attachment changes, so the wizard always holds the
|
|
101
|
+
* complete payload (incl. attachment_keys) to review and submit. Read-only
|
|
102
|
+
* consumers omit it. */
|
|
103
|
+
onchange?: (payload: Record<string, unknown>) => void;
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
let {
|
|
@@ -102,6 +118,9 @@
|
|
|
102
118
|
layers = [],
|
|
103
119
|
onoutofbounds = undefined,
|
|
104
120
|
geoError = undefined,
|
|
121
|
+
activeSectionKey = null,
|
|
122
|
+
hideSubmit = false,
|
|
123
|
+
onchange = undefined,
|
|
105
124
|
}: Props = $props();
|
|
106
125
|
|
|
107
126
|
let values = $state<Record<string, unknown>>({});
|
|
@@ -125,7 +144,9 @@
|
|
|
125
144
|
// consumer wired an apply. Preview modes (admin-preview/adapter-preview) and a
|
|
126
145
|
// missing onApply stay read-only — the button never renders.
|
|
127
146
|
const isSubmitMode = $derived(mode === "public-submit" || mode === "admin-execute");
|
|
128
|
-
|
|
147
|
+
// The wizard (#105 P6) suppresses the internal button via hideSubmit — it
|
|
148
|
+
// owns the nav and posts once at the end.
|
|
149
|
+
const showSubmit = $derived(isSubmitMode && onApply !== undefined && !hideSubmit);
|
|
129
150
|
|
|
130
151
|
const renderedAction = $derived((schema?.action as Entity | null | undefined) ?? action);
|
|
131
152
|
const renderedPlacement = $derived((schema?.placement as Entity | null | undefined) ?? placement);
|
|
@@ -146,9 +167,23 @@
|
|
|
146
167
|
);
|
|
147
168
|
const schemaSections = $derived(schemaSectionsFromContract());
|
|
148
169
|
const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
|
|
170
|
+
// Wizard pagination (#105 P6): when a section is active, render only it. The
|
|
171
|
+
// value bag is unaffected — every parameter is still seeded and submitted.
|
|
172
|
+
const displaySections = $derived(
|
|
173
|
+
activeSectionKey == null
|
|
174
|
+
? sections
|
|
175
|
+
: sections.filter((section) => section.name === activeSectionKey),
|
|
176
|
+
);
|
|
149
177
|
const payload = $derived(buildPayload());
|
|
150
178
|
const payloadJson = $derived(JSON.stringify(payload, null, 2));
|
|
151
179
|
|
|
180
|
+
// Wizard (#105 P6): surface the live payload so the shell can review + submit
|
|
181
|
+
// it. Reading `payload` registers the dependency; the effect re-runs whenever
|
|
182
|
+
// a value or attachment changes.
|
|
183
|
+
$effect(() => {
|
|
184
|
+
onchange?.(payload);
|
|
185
|
+
});
|
|
186
|
+
|
|
152
187
|
// Client-side submit gate (layer 1 — see the ADL): every required, visible
|
|
153
188
|
// parameter must have a non-empty value. Server-side submission criteria are
|
|
154
189
|
// enforced by the BFF on submit and surfaced via `submitError`.
|
|
@@ -520,21 +555,26 @@
|
|
|
520
555
|
{/snippet}
|
|
521
556
|
|
|
522
557
|
<div class="renderer" data-testid={`action-form-renderer-${mode}`} data-layout={resolvedLayout.key}>
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
558
|
+
<!-- Wizard pagination (#105 P6): the ServiceFlow shell owns the step
|
|
559
|
+
heading, so the renderer's own action-title header is suppressed when a
|
|
560
|
+
section is active to avoid a duplicate heading per step. -->
|
|
561
|
+
{#if activeSectionKey == null}
|
|
562
|
+
<div class="renderer-header">
|
|
563
|
+
<div>
|
|
564
|
+
<!-- The placement-preview eyebrow + surface badge are operator chrome —
|
|
565
|
+
hidden in public-submit so a citizen sees a clean form title. -->
|
|
566
|
+
{#if mode !== "public-submit"}
|
|
567
|
+
<p class="eyebrow">Placement preview</p>
|
|
568
|
+
{/if}
|
|
569
|
+
<h3>{String(renderedAction?.label ?? renderedAction?.key ?? "Select an action")}</h3>
|
|
570
|
+
</div>
|
|
527
571
|
{#if mode !== "public-submit"}
|
|
528
|
-
<
|
|
572
|
+
<Badge variant={renderedPlacement ? "info" : "neutral"}>
|
|
573
|
+
{String(renderedPlacement?.surface ?? "No placement")}
|
|
574
|
+
</Badge>
|
|
529
575
|
{/if}
|
|
530
|
-
<h3>{String(renderedAction?.label ?? renderedAction?.key ?? "Select an action")}</h3>
|
|
531
576
|
</div>
|
|
532
|
-
|
|
533
|
-
<Badge variant={renderedPlacement ? "info" : "neutral"}>
|
|
534
|
-
{String(renderedPlacement?.surface ?? "No placement")}
|
|
535
|
-
</Badge>
|
|
536
|
-
{/if}
|
|
537
|
-
</div>
|
|
577
|
+
{/if}
|
|
538
578
|
|
|
539
579
|
{#if !renderedAction}
|
|
540
580
|
<p class="muted">Select an action to preview its placement-aware form contract.</p>
|
|
@@ -550,7 +590,7 @@
|
|
|
550
590
|
renderedAction?.label ?? renderedAction?.key ?? "Form",
|
|
551
591
|
)}
|
|
552
592
|
>
|
|
553
|
-
<Layout {
|
|
593
|
+
<Layout sections={displaySections} field={fieldRow} />
|
|
554
594
|
</form>
|
|
555
595
|
{/if}
|
|
556
596
|
|
|
@@ -70,6 +70,22 @@ interface Props {
|
|
|
70
70
|
/** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
|
|
71
71
|
* out-of-bounds copy) — forwarded as MapPicker's `error`. */
|
|
72
72
|
geoError?: string;
|
|
73
|
+
/** Service-flow wizard (#105 P6): render ONLY the section whose
|
|
74
|
+
* name (its label, falling back to its key) matches — one screen per
|
|
75
|
+
* step. The value bag still spans ALL parameters (so answers persist
|
|
76
|
+
* across steps and the final payload is complete); only the DISPLAY is
|
|
77
|
+
* scoped. Null (default) renders every section, byte-identical to today. */
|
|
78
|
+
activeSectionKey?: string | null;
|
|
79
|
+
/** Service-flow wizard (#105 P6): suppress the renderer's own submit
|
|
80
|
+
* button / captcha / error — the wizard shell owns the nav and posts the
|
|
81
|
+
* payload once at the end. Default false (the standalone form keeps its
|
|
82
|
+
* button). */
|
|
83
|
+
hideSubmit?: boolean;
|
|
84
|
+
/** Service-flow wizard (#105 P6): fires with the freshly built payload
|
|
85
|
+
* whenever a value or attachment changes, so the wizard always holds the
|
|
86
|
+
* complete payload (incl. attachment_keys) to review and submit. Read-only
|
|
87
|
+
* consumers omit it. */
|
|
88
|
+
onchange?: (payload: Record<string, unknown>) => void;
|
|
73
89
|
}
|
|
74
90
|
declare const ActionFormRenderer: import("svelte").Component<Props, {}, "">;
|
|
75
91
|
type ActionFormRenderer = ReturnType<typeof ActionFormRenderer>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component CheckAnswers
|
|
3
|
+
|
|
4
|
+
The "review your answers before submitting" summary — a grouped description
|
|
5
|
+
list of every answer the citizen gave, each with a "Change" link back to the
|
|
6
|
+
step that captured it. Vertical-agnostic: it renders whatever label/value
|
|
7
|
+
pairs the consumer collected (from the action's parameter labels), grouped by
|
|
8
|
+
the flow's sections. Pure presentation — no state, no network.
|
|
9
|
+
|
|
10
|
+
Accessibility (WCAG 3.3.4, Error Prevention): every answer is reviewable and
|
|
11
|
+
editable before the irreversible submit. Each "Change" control carries an
|
|
12
|
+
accessible name that includes WHAT it changes (the visible "Change" plus a
|
|
13
|
+
visually-hidden field label), so a screen-reader user never meets a row of
|
|
14
|
+
identical "Change" links. Answers are a `<dl>` (the semantic structure for
|
|
15
|
+
term/description pairs); section headings are `<h3>` under the widget's `h2`.
|
|
16
|
+
|
|
17
|
+
@example
|
|
18
|
+
<CheckAnswers
|
|
19
|
+
label="Check your answers"
|
|
20
|
+
groups={[
|
|
21
|
+
{ label: "About you", items: [
|
|
22
|
+
{ key: "name", label: "Full name", value: "Ana Silva", onChange: () => goTo(0) },
|
|
23
|
+
]},
|
|
24
|
+
{ label: "Your idea", items: [
|
|
25
|
+
{ key: "title", label: "Title", value: "Bike lanes", onChange: () => goTo(1) },
|
|
26
|
+
]},
|
|
27
|
+
]}
|
|
28
|
+
/>
|
|
29
|
+
-->
|
|
30
|
+
<script module>
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{ key: string, label: string, value: string, onChange?: () => void }} AnswerItem
|
|
33
|
+
* @typedef {{ label?: string, items: AnswerItem[] }} AnswerGroup
|
|
34
|
+
*/
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
let {
|
|
39
|
+
/** @type {AnswerGroup[]} The collected answers, grouped by section. */
|
|
40
|
+
groups = [],
|
|
41
|
+
/** @type {string} Accessible name for the review region (localize it). */
|
|
42
|
+
label = "Check your answers",
|
|
43
|
+
/** @type {string} Visible text on each change control (localize it). */
|
|
44
|
+
changeLabel = "Change",
|
|
45
|
+
/** @type {string} Shown when there are no answers yet (localize it). */
|
|
46
|
+
emptyText = "There are no answers to review yet.",
|
|
47
|
+
/** @type {string} Placeholder for an answer the citizen left blank (localize it). */
|
|
48
|
+
blankText = "Not provided",
|
|
49
|
+
|
|
50
|
+
/** @type {string} */
|
|
51
|
+
class: className = "",
|
|
52
|
+
...rest
|
|
53
|
+
} = $props();
|
|
54
|
+
|
|
55
|
+
const hasAnswers = $derived(groups.some((g) => g.items.length > 0));
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<section class="check-answers {className}" aria-label={label} {...rest}>
|
|
59
|
+
{#if hasAnswers}
|
|
60
|
+
{#each groups as group (group.label ?? group.items[0]?.key)}
|
|
61
|
+
{#if group.items.length > 0}
|
|
62
|
+
<div class="check-answers-group">
|
|
63
|
+
{#if group.label}
|
|
64
|
+
<h3 class="check-answers-group-heading">{group.label}</h3>
|
|
65
|
+
{/if}
|
|
66
|
+
<dl class="check-answers-list">
|
|
67
|
+
{#each group.items as item (item.key)}
|
|
68
|
+
<div class="check-answers-row">
|
|
69
|
+
<dt class="check-answers-term">{item.label}</dt>
|
|
70
|
+
<dd class="check-answers-value">
|
|
71
|
+
{item.value || blankText}
|
|
72
|
+
</dd>
|
|
73
|
+
<dd class="check-answers-action">
|
|
74
|
+
{#if item.onChange}
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="check-answers-change"
|
|
78
|
+
onclick={() => item.onChange?.()}
|
|
79
|
+
>
|
|
80
|
+
{changeLabel}<span class="check-answers-sr"
|
|
81
|
+
> {item.label}</span
|
|
82
|
+
>
|
|
83
|
+
</button>
|
|
84
|
+
{/if}
|
|
85
|
+
</dd>
|
|
86
|
+
</div>
|
|
87
|
+
{/each}
|
|
88
|
+
</dl>
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
91
|
+
{/each}
|
|
92
|
+
{:else}
|
|
93
|
+
<p class="check-answers-empty">{emptyText}</p>
|
|
94
|
+
{/if}
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
.check-answers {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: var(--space-lg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.check-answers-group {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
gap: var(--space-sm);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.check-answers-group-heading {
|
|
111
|
+
font-family: var(--type-heading-sm-font);
|
|
112
|
+
font-size: var(--type-heading-sm-size);
|
|
113
|
+
color: var(--color-text);
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.check-answers-list {
|
|
118
|
+
margin: 0;
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.check-answers-row {
|
|
124
|
+
display: grid;
|
|
125
|
+
grid-template-columns: minmax(8rem, 1fr) 2fr auto;
|
|
126
|
+
gap: var(--space-sm);
|
|
127
|
+
align-items: start;
|
|
128
|
+
padding: var(--space-sm) 0;
|
|
129
|
+
border-bottom: 1px solid var(--color-border);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.check-answers-term {
|
|
133
|
+
font-family: var(--type-body-sm-font);
|
|
134
|
+
font-size: var(--type-body-sm-size);
|
|
135
|
+
color: var(--color-text-muted);
|
|
136
|
+
margin: 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.check-answers-value {
|
|
140
|
+
font-family: var(--type-body-font);
|
|
141
|
+
font-size: var(--type-body-size);
|
|
142
|
+
color: var(--color-text);
|
|
143
|
+
margin: 0;
|
|
144
|
+
overflow-wrap: anywhere;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.check-answers-action {
|
|
148
|
+
margin: 0;
|
|
149
|
+
justify-self: end;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.check-answers-change {
|
|
153
|
+
appearance: none;
|
|
154
|
+
background: none;
|
|
155
|
+
border: none;
|
|
156
|
+
padding: 0;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
font-family: var(--type-body-sm-font);
|
|
159
|
+
font-size: var(--type-body-sm-size);
|
|
160
|
+
color: var(--color-accent);
|
|
161
|
+
text-decoration: underline;
|
|
162
|
+
text-underline-offset: 2px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.check-answers-change:hover {
|
|
166
|
+
color: var(--color-accent-hover);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.check-answers-empty {
|
|
170
|
+
font-family: var(--type-body-font);
|
|
171
|
+
font-size: var(--type-body-size);
|
|
172
|
+
color: var(--color-text-muted);
|
|
173
|
+
margin: 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Visually-hidden text that screen readers still announce (WCAG 3.3.4). */
|
|
177
|
+
.check-answers-sr {
|
|
178
|
+
position: absolute;
|
|
179
|
+
width: 1px;
|
|
180
|
+
height: 1px;
|
|
181
|
+
padding: 0;
|
|
182
|
+
margin: -1px;
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
clip: rect(0, 0, 0, 0);
|
|
185
|
+
white-space: nowrap;
|
|
186
|
+
border: 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@media (max-width: 30rem) {
|
|
190
|
+
.check-answers-row {
|
|
191
|
+
grid-template-columns: 1fr auto;
|
|
192
|
+
}
|
|
193
|
+
.check-answers-term {
|
|
194
|
+
grid-column: 1 / -1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export default CheckAnswers;
|
|
2
|
+
export type AnswerItem = {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
value: string;
|
|
6
|
+
onChange?: () => void;
|
|
7
|
+
};
|
|
8
|
+
export type AnswerGroup = {
|
|
9
|
+
label?: string;
|
|
10
|
+
items: AnswerItem[];
|
|
11
|
+
};
|
|
12
|
+
type CheckAnswers = {
|
|
13
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
14
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* CheckAnswers
|
|
18
|
+
*
|
|
19
|
+
* The "review your answers before submitting" summary — a grouped description
|
|
20
|
+
* list of every answer the citizen gave, each with a "Change" link back to the
|
|
21
|
+
* step that captured it. Vertical-agnostic: it renders whatever label/value
|
|
22
|
+
* pairs the consumer collected (from the action's parameter labels), grouped by
|
|
23
|
+
* the flow's sections. Pure presentation — no state, no network.
|
|
24
|
+
*
|
|
25
|
+
* Accessibility (WCAG 3.3.4, Error Prevention): every answer is reviewable and
|
|
26
|
+
* editable before the irreversible submit. Each "Change" control carries an
|
|
27
|
+
* accessible name that includes WHAT it changes (the visible "Change" plus a
|
|
28
|
+
* visually-hidden field label), so a screen-reader user never meets a row of
|
|
29
|
+
* identical "Change" links. Answers are a `<dl>` (the semantic structure for
|
|
30
|
+
* term/description pairs); section headings are `<h3>` under the widget's `h2`.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* <CheckAnswers
|
|
34
|
+
* label="Check your answers"
|
|
35
|
+
* groups={[
|
|
36
|
+
* { label: "About you", items: [
|
|
37
|
+
* { key: "name", label: "Full name", value: "Ana Silva", onChange: () => goTo(0) },
|
|
38
|
+
* ]},
|
|
39
|
+
* { label: "Your idea", items: [
|
|
40
|
+
* { key: "title", label: "Title", value: "Bike lanes", onChange: () => goTo(1) },
|
|
41
|
+
* ]},
|
|
42
|
+
* ]}
|
|
43
|
+
* />
|
|
44
|
+
*/
|
|
45
|
+
declare const CheckAnswers: import("svelte").Component<{
|
|
46
|
+
groups?: any[];
|
|
47
|
+
label?: string;
|
|
48
|
+
changeLabel?: string;
|
|
49
|
+
emptyText?: string;
|
|
50
|
+
blankText?: string;
|
|
51
|
+
class?: string;
|
|
52
|
+
} & Record<string, any>, {}, "">;
|
|
53
|
+
type $$ComponentProps = {
|
|
54
|
+
groups?: any[];
|
|
55
|
+
label?: string;
|
|
56
|
+
changeLabel?: string;
|
|
57
|
+
emptyText?: string;
|
|
58
|
+
blankText?: string;
|
|
59
|
+
class?: string;
|
|
60
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Confirmation
|
|
3
|
+
|
|
4
|
+
The receipt panel shown after a successful submission: a success banner with
|
|
5
|
+
the citizen's reference number front-and-centre, a "what happens next" body,
|
|
6
|
+
and an optional link to track the submission. Vertical-agnostic — it knows
|
|
7
|
+
nothing about proposals or occurrences, only "here is your reference and what
|
|
8
|
+
to expect". Pure presentation; the consumer fetched the reference.
|
|
9
|
+
|
|
10
|
+
Accessibility: a `<section>` region named by `label`; the success heading is
|
|
11
|
+
an `<h2>` (the page `<h1>` is the consumer's, same split as the other account
|
|
12
|
+
widgets) carrying a decorative check. The reference is emphasised text inside
|
|
13
|
+
a labelled block, not colour alone. Semantic tokens throughout, so the
|
|
14
|
+
success styling survives dark / high-contrast schemes (#244).
|
|
15
|
+
|
|
16
|
+
@example
|
|
17
|
+
<Confirmation
|
|
18
|
+
label="Submission received"
|
|
19
|
+
title="Your proposal has been submitted"
|
|
20
|
+
reference="PRP-2026-0042"
|
|
21
|
+
referenceLabel="Your reference number"
|
|
22
|
+
body="We've sent a confirmation. The committee will review your proposal."
|
|
23
|
+
trackHref="/participacao/acompanhar/PRP-2026-0042"
|
|
24
|
+
trackLabel="Track this proposal"
|
|
25
|
+
/>
|
|
26
|
+
-->
|
|
27
|
+
<script>
|
|
28
|
+
import Button from "./Button.svelte";
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
/** @type {string} Accessible name for the confirmation region (localize it). */
|
|
32
|
+
label = "Confirmation",
|
|
33
|
+
/** @type {string} The success headline (localize it). */
|
|
34
|
+
title = "Submission received",
|
|
35
|
+
/** @type {string} The reference / tracking number to surface. */
|
|
36
|
+
reference = "",
|
|
37
|
+
/** @type {string} Label above the reference number (localize it). */
|
|
38
|
+
referenceLabel = "Your reference number",
|
|
39
|
+
/** @type {string} The "what happens next" copy (localize it). */
|
|
40
|
+
body = "",
|
|
41
|
+
/** @type {string | undefined} Link to track the submission. */
|
|
42
|
+
trackHref = undefined,
|
|
43
|
+
/** @type {string} Label for the track link (localize it). */
|
|
44
|
+
trackLabel = "Track your submission",
|
|
45
|
+
|
|
46
|
+
/** @type {import('svelte').Snippet | undefined} Extra content (e.g. a next-steps list). */
|
|
47
|
+
children = undefined,
|
|
48
|
+
|
|
49
|
+
/** @type {string} */
|
|
50
|
+
class: className = "",
|
|
51
|
+
...rest
|
|
52
|
+
} = $props();
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<section class="confirmation {className}" aria-label={label} {...rest}>
|
|
56
|
+
<div class="confirmation-banner">
|
|
57
|
+
<span class="confirmation-icon" aria-hidden="true">
|
|
58
|
+
<svg viewBox="0 0 24 24" fill="none">
|
|
59
|
+
<path
|
|
60
|
+
d="M5 12.5l4.5 4.5L19 7.5"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
stroke-width="2.5"
|
|
63
|
+
stroke-linecap="round"
|
|
64
|
+
stroke-linejoin="round"
|
|
65
|
+
/>
|
|
66
|
+
</svg>
|
|
67
|
+
</span>
|
|
68
|
+
<h2 class="confirmation-title">{title}</h2>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{#if reference}
|
|
72
|
+
<div class="confirmation-reference">
|
|
73
|
+
<span class="confirmation-reference-label">{referenceLabel}</span>
|
|
74
|
+
<strong class="confirmation-reference-value">{reference}</strong>
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
{#if body}
|
|
79
|
+
<p class="confirmation-body">{body}</p>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
{#if children}
|
|
83
|
+
<div class="confirmation-extra">{@render children()}</div>
|
|
84
|
+
{/if}
|
|
85
|
+
|
|
86
|
+
{#if trackHref}
|
|
87
|
+
<div class="confirmation-actions">
|
|
88
|
+
<Button variant="secondary" href={trackHref}>{trackLabel}</Button>
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<style>
|
|
94
|
+
.confirmation {
|
|
95
|
+
display: flex;
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
gap: var(--space-lg);
|
|
98
|
+
padding: var(--space-xl);
|
|
99
|
+
border: 1px solid var(--color-success);
|
|
100
|
+
border-radius: var(--radius-lg);
|
|
101
|
+
background: var(--color-success-subtle);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.confirmation-banner {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: var(--space-md);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.confirmation-icon {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
flex-shrink: 0;
|
|
115
|
+
width: 2.5rem;
|
|
116
|
+
height: 2.5rem;
|
|
117
|
+
border-radius: var(--radius-circle);
|
|
118
|
+
background: var(--color-success);
|
|
119
|
+
color: var(--color-surface);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.confirmation-icon svg {
|
|
123
|
+
width: 1.5rem;
|
|
124
|
+
height: 1.5rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.confirmation-title {
|
|
128
|
+
font-family: var(--type-heading-font);
|
|
129
|
+
font-size: var(--type-heading-size);
|
|
130
|
+
color: var(--color-text);
|
|
131
|
+
margin: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.confirmation-reference {
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: var(--space-2xs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.confirmation-reference-label {
|
|
141
|
+
font-family: var(--type-body-sm-font);
|
|
142
|
+
font-size: var(--type-body-sm-size);
|
|
143
|
+
color: var(--color-text-muted);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.confirmation-reference-value {
|
|
147
|
+
font-family: var(--type-data-font);
|
|
148
|
+
font-size: var(--type-heading-sm-size);
|
|
149
|
+
letter-spacing: var(--type-data-tracking);
|
|
150
|
+
color: var(--color-text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.confirmation-body {
|
|
154
|
+
font-family: var(--type-body-font);
|
|
155
|
+
font-size: var(--type-body-size);
|
|
156
|
+
line-height: var(--type-body-leading);
|
|
157
|
+
color: var(--color-text);
|
|
158
|
+
margin: 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.confirmation-extra {
|
|
162
|
+
font-family: var(--type-body-font);
|
|
163
|
+
font-size: var(--type-body-size);
|
|
164
|
+
color: var(--color-text);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.confirmation-actions {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-wrap: wrap;
|
|
170
|
+
gap: var(--space-sm);
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export default Confirmation;
|
|
2
|
+
type Confirmation = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Confirmation
|
|
8
|
+
*
|
|
9
|
+
* The receipt panel shown after a successful submission: a success banner with
|
|
10
|
+
* the citizen's reference number front-and-centre, a "what happens next" body,
|
|
11
|
+
* and an optional link to track the submission. Vertical-agnostic — it knows
|
|
12
|
+
* nothing about proposals or occurrences, only "here is your reference and what
|
|
13
|
+
* to expect". Pure presentation; the consumer fetched the reference.
|
|
14
|
+
*
|
|
15
|
+
* Accessibility: a `<section>` region named by `label`; the success heading is
|
|
16
|
+
* an `<h2>` (the page `<h1>` is the consumer's, same split as the other account
|
|
17
|
+
* widgets) carrying a decorative check. The reference is emphasised text inside
|
|
18
|
+
* a labelled block, not colour alone. Semantic tokens throughout, so the
|
|
19
|
+
* success styling survives dark / high-contrast schemes (#244).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Confirmation
|
|
23
|
+
* label="Submission received"
|
|
24
|
+
* title="Your proposal has been submitted"
|
|
25
|
+
* reference="PRP-2026-0042"
|
|
26
|
+
* referenceLabel="Your reference number"
|
|
27
|
+
* body="We've sent a confirmation. The committee will review your proposal."
|
|
28
|
+
* trackHref="/participacao/acompanhar/PRP-2026-0042"
|
|
29
|
+
* trackLabel="Track this proposal"
|
|
30
|
+
* />
|
|
31
|
+
*/
|
|
32
|
+
declare const Confirmation: import("svelte").Component<{
|
|
33
|
+
label?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
reference?: string;
|
|
36
|
+
referenceLabel?: string;
|
|
37
|
+
body?: string;
|
|
38
|
+
trackHref?: any;
|
|
39
|
+
trackLabel?: string;
|
|
40
|
+
children?: any;
|
|
41
|
+
class?: string;
|
|
42
|
+
} & Record<string, any>, {}, "">;
|
|
43
|
+
type $$ComponentProps = {
|
|
44
|
+
label?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
reference?: string;
|
|
47
|
+
referenceLabel?: string;
|
|
48
|
+
body?: string;
|
|
49
|
+
trackHref?: any;
|
|
50
|
+
trackLabel?: string;
|
|
51
|
+
children?: any;
|
|
52
|
+
class?: string;
|
|
53
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ConsentManager
|
|
3
|
+
|
|
4
|
+
The citizen account area's RGPD panel — consent management + the two data
|
|
5
|
+
rights (portability + erasure). Three parts, all vertical-agnostic:
|
|
6
|
+
|
|
7
|
+
1. A toggle list of CONSENT PURPOSES (`items`, already mapped to
|
|
8
|
+
`{ id, label, description?, active }`). `active` = "I hold a current
|
|
9
|
+
consent for this purpose". Toggling calls `onToggle(id, next)`; the
|
|
10
|
+
consumer grants (next=true) or revokes (next=false) and re-feeds `items`.
|
|
11
|
+
2. EXPORT MY DATA — a button that triggers `onExport` (the consumer
|
|
12
|
+
downloads the citizen's own data, RGPD portability / Anexo II 121).
|
|
13
|
+
3. DELETE MY ACCOUNT — a DESTRUCTIVE, type-to-confirm flow (Anexo II 122):
|
|
14
|
+
the citizen must type `confirmPhrase` before the irreversible
|
|
15
|
+
`onErase` is reachable. No single-click deletion.
|
|
16
|
+
|
|
17
|
+
Accessibility-first: a `<section>` region named by `label` (the visible page
|
|
18
|
+
heading is the consumer's job — same split as NotificationPrefs/RankingBoard,
|
|
19
|
+
so one widget can own the page `<h1>`). The data-rights block is a labelled
|
|
20
|
+
`<h3>` sub-section (the widget heading sits at h2 above it). Toggles are
|
|
21
|
+
`role="switch"` with their own accessible name; the confirm button stays
|
|
22
|
+
`disabled` until the typed phrase matches. Semantic tokens only, so dark /
|
|
23
|
+
high-contrast schemes (#244) ride through. Soft-empty: no purposes → the
|
|
24
|
+
`emptyText` note (the data rights still render).
|
|
25
|
+
|
|
26
|
+
@example
|
|
27
|
+
<ConsentManager
|
|
28
|
+
label="Privacy & consent"
|
|
29
|
+
items={[
|
|
30
|
+
{ id: "campaign_outreach", label: "Campaign updates", description: "Email about participatory budgeting", active: true },
|
|
31
|
+
{ id: "analytics", label: "Usage analytics", active: false },
|
|
32
|
+
]}
|
|
33
|
+
onToggle={(id, next) => save(id, next)}
|
|
34
|
+
busyId={savingId}
|
|
35
|
+
onExport={() => downloadMyData()}
|
|
36
|
+
confirmPhrase="DELETE"
|
|
37
|
+
onErase={() => eraseAccount()}
|
|
38
|
+
/>
|
|
39
|
+
-->
|
|
40
|
+
<script module>
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{ id: string, label: string, description?: string, active: boolean }} ConsentItem
|
|
43
|
+
*/
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
import List from "./List.svelte";
|
|
48
|
+
import ListItem from "./ListItem.svelte";
|
|
49
|
+
import Toggle from "./Toggle.svelte";
|
|
50
|
+
import Button from "./Button.svelte";
|
|
51
|
+
import Input from "./Input.svelte";
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
/** @type {ConsentItem[]} Consent purposes, already mapped to labels. */
|
|
55
|
+
items = [],
|
|
56
|
+
/** @type {string} Accessible name for the consent region (localize it). */
|
|
57
|
+
label = "Consent preferences",
|
|
58
|
+
/** @type {string} Shown when there are no purposes (localize it). */
|
|
59
|
+
emptyText = "There are no consent purposes to manage yet.",
|
|
60
|
+
/** @type {((id: string, active: boolean) => void) | undefined} Toggle a purpose on/off. */
|
|
61
|
+
onToggle = undefined,
|
|
62
|
+
/** @type {string | null} The purpose currently saving — its toggle disables. */
|
|
63
|
+
busyId = null,
|
|
64
|
+
|
|
65
|
+
/** @type {string} Heading for the data-rights sub-section (localize it). */
|
|
66
|
+
dataRightsLabel = "Your data",
|
|
67
|
+
/** @type {string} Label for the export button (localize it). */
|
|
68
|
+
exportLabel = "Export my data",
|
|
69
|
+
/** @type {string} Helper line under the export button (localize it). */
|
|
70
|
+
exportHelp = "Download a copy of your personal data.",
|
|
71
|
+
/** @type {(() => void) | undefined} Called when the citizen exports their data. */
|
|
72
|
+
onExport = undefined,
|
|
73
|
+
/** @type {boolean} The export is in flight. */
|
|
74
|
+
exporting = false,
|
|
75
|
+
|
|
76
|
+
/** @type {string} Label for the destructive trigger button (localize it). */
|
|
77
|
+
eraseLabel = "Delete my account",
|
|
78
|
+
/** @type {string} The irreversibility warning shown when confirming (localize it). */
|
|
79
|
+
erasePrompt = "This permanently deletes your account and personal data and cannot be undone.",
|
|
80
|
+
/** @type {string} The exact phrase the citizen must type to confirm. */
|
|
81
|
+
confirmPhrase = "DELETE",
|
|
82
|
+
/** @type {string} Label for the type-to-confirm input (localize it). */
|
|
83
|
+
confirmInputLabel = "Type the word below to confirm",
|
|
84
|
+
/** @type {string} Label for the final destructive button (localize it). */
|
|
85
|
+
confirmButtonLabel = "Delete my account permanently",
|
|
86
|
+
/** @type {string} Label for the cancel button (localize it). */
|
|
87
|
+
cancelLabel = "Cancel",
|
|
88
|
+
/** @type {(() => void) | undefined} Called once the typed phrase matches. */
|
|
89
|
+
onErase = undefined,
|
|
90
|
+
/** @type {boolean} The erasure request is in flight. */
|
|
91
|
+
erasing = false,
|
|
92
|
+
|
|
93
|
+
/** @type {string} */
|
|
94
|
+
class: className = "",
|
|
95
|
+
...rest
|
|
96
|
+
} = $props();
|
|
97
|
+
|
|
98
|
+
// Local confirm state — the destructive flow stays closed until the citizen
|
|
99
|
+
// opts in, and the irreversible action stays unreachable until they type the
|
|
100
|
+
// exact phrase. The consumer owns the network call (onErase).
|
|
101
|
+
let confirming = $state(false);
|
|
102
|
+
let typed = $state("");
|
|
103
|
+
const canErase = $derived(typed.trim() === confirmPhrase);
|
|
104
|
+
|
|
105
|
+
function startConfirm() {
|
|
106
|
+
confirming = true;
|
|
107
|
+
typed = "";
|
|
108
|
+
}
|
|
109
|
+
function cancelConfirm() {
|
|
110
|
+
confirming = false;
|
|
111
|
+
typed = "";
|
|
112
|
+
}
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<section class="consent-manager {className}" aria-label={label} {...rest}>
|
|
116
|
+
{#if items.length > 0}
|
|
117
|
+
<List variant="bordered">
|
|
118
|
+
{#each items as item (item.id)}
|
|
119
|
+
<ListItem>
|
|
120
|
+
{#snippet leading()}
|
|
121
|
+
<span class="consent-manager-label">{item.label}</span>
|
|
122
|
+
{#if item.description}
|
|
123
|
+
<span class="consent-manager-desc">{item.description}</span>
|
|
124
|
+
{/if}
|
|
125
|
+
{/snippet}
|
|
126
|
+
{#snippet trailing()}
|
|
127
|
+
<Toggle
|
|
128
|
+
checked={item.active}
|
|
129
|
+
disabled={busyId === item.id}
|
|
130
|
+
aria-label={item.label}
|
|
131
|
+
onchange={(next) => onToggle?.(item.id, next)}
|
|
132
|
+
/>
|
|
133
|
+
{/snippet}
|
|
134
|
+
</ListItem>
|
|
135
|
+
{/each}
|
|
136
|
+
</List>
|
|
137
|
+
{:else}
|
|
138
|
+
<p class="consent-manager-empty">{emptyText}</p>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
<div class="consent-manager-rights">
|
|
142
|
+
<h3 class="consent-manager-rights-heading">{dataRightsLabel}</h3>
|
|
143
|
+
|
|
144
|
+
<div class="consent-manager-action">
|
|
145
|
+
<Button variant="secondary" loading={exporting} onclick={() => onExport?.()}>
|
|
146
|
+
{exportLabel}
|
|
147
|
+
</Button>
|
|
148
|
+
<p class="consent-manager-help">{exportHelp}</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="consent-manager-danger">
|
|
152
|
+
{#if !confirming}
|
|
153
|
+
<Button variant="destructive" onclick={startConfirm}>{eraseLabel}</Button>
|
|
154
|
+
{:else}
|
|
155
|
+
<p class="consent-manager-warning" role="alert">{erasePrompt}</p>
|
|
156
|
+
<Input
|
|
157
|
+
label={confirmInputLabel}
|
|
158
|
+
placeholder={confirmPhrase}
|
|
159
|
+
bind:value={typed}
|
|
160
|
+
autocomplete="off"
|
|
161
|
+
/>
|
|
162
|
+
<p class="consent-manager-help">{confirmPhrase}</p>
|
|
163
|
+
<div class="consent-manager-confirm-row">
|
|
164
|
+
<Button
|
|
165
|
+
variant="destructive"
|
|
166
|
+
disabled={!canErase || erasing}
|
|
167
|
+
loading={erasing}
|
|
168
|
+
onclick={() => onErase?.()}
|
|
169
|
+
>
|
|
170
|
+
{confirmButtonLabel}
|
|
171
|
+
</Button>
|
|
172
|
+
<Button variant="ghost" disabled={erasing} onclick={cancelConfirm}>
|
|
173
|
+
{cancelLabel}
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
{/if}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</section>
|
|
180
|
+
|
|
181
|
+
<style>
|
|
182
|
+
.consent-manager {
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
gap: var(--space-lg);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.consent-manager-label {
|
|
189
|
+
font-family: var(--type-body-font);
|
|
190
|
+
font-size: var(--type-body-size);
|
|
191
|
+
color: var(--color-text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.consent-manager-desc {
|
|
195
|
+
font-family: var(--type-body-sm-font);
|
|
196
|
+
font-size: var(--type-body-sm-size);
|
|
197
|
+
color: var(--color-text-muted);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.consent-manager-empty {
|
|
201
|
+
font-family: var(--type-body-font);
|
|
202
|
+
font-size: var(--type-body-size);
|
|
203
|
+
color: var(--color-text-muted);
|
|
204
|
+
margin: 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.consent-manager-rights {
|
|
208
|
+
display: flex;
|
|
209
|
+
flex-direction: column;
|
|
210
|
+
gap: var(--space-md);
|
|
211
|
+
padding-top: var(--space-md);
|
|
212
|
+
border-top: 1px solid var(--color-border);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.consent-manager-rights-heading {
|
|
216
|
+
font-family: var(--type-heading-sm-font);
|
|
217
|
+
font-size: var(--type-heading-sm-size);
|
|
218
|
+
color: var(--color-text);
|
|
219
|
+
margin: 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.consent-manager-action,
|
|
223
|
+
.consent-manager-danger {
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-direction: column;
|
|
226
|
+
gap: var(--space-xs);
|
|
227
|
+
align-items: flex-start;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.consent-manager-help {
|
|
231
|
+
font-family: var(--type-body-sm-font);
|
|
232
|
+
font-size: var(--type-body-sm-size);
|
|
233
|
+
color: var(--color-text-muted);
|
|
234
|
+
margin: 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.consent-manager-warning {
|
|
238
|
+
font-family: var(--type-body-font);
|
|
239
|
+
font-size: var(--type-body-size);
|
|
240
|
+
color: var(--color-destructive);
|
|
241
|
+
margin: 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.consent-manager-confirm-row {
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-wrap: wrap;
|
|
247
|
+
gap: var(--space-sm);
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export default ConsentManager;
|
|
2
|
+
export type ConsentItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
active: boolean;
|
|
7
|
+
};
|
|
8
|
+
type ConsentManager = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* ConsentManager
|
|
14
|
+
*
|
|
15
|
+
* The citizen account area's RGPD panel — consent management + the two data
|
|
16
|
+
* rights (portability + erasure). Three parts, all vertical-agnostic:
|
|
17
|
+
*
|
|
18
|
+
* 1. A toggle list of CONSENT PURPOSES (`items`, already mapped to
|
|
19
|
+
* `{ id, label, description?, active }`). `active` = "I hold a current
|
|
20
|
+
* consent for this purpose". Toggling calls `onToggle(id, next)`; the
|
|
21
|
+
* consumer grants (next=true) or revokes (next=false) and re-feeds `items`.
|
|
22
|
+
* 2. EXPORT MY DATA — a button that triggers `onExport` (the consumer
|
|
23
|
+
* downloads the citizen's own data, RGPD portability / Anexo II 121).
|
|
24
|
+
* 3. DELETE MY ACCOUNT — a DESTRUCTIVE, type-to-confirm flow (Anexo II 122):
|
|
25
|
+
* the citizen must type `confirmPhrase` before the irreversible
|
|
26
|
+
* `onErase` is reachable. No single-click deletion.
|
|
27
|
+
*
|
|
28
|
+
* Accessibility-first: a `<section>` region named by `label` (the visible page
|
|
29
|
+
* heading is the consumer's job — same split as NotificationPrefs/RankingBoard,
|
|
30
|
+
* so one widget can own the page `<h1>`). The data-rights block is a labelled
|
|
31
|
+
* `<h3>` sub-section (the widget heading sits at h2 above it). Toggles are
|
|
32
|
+
* `role="switch"` with their own accessible name; the confirm button stays
|
|
33
|
+
* `disabled` until the typed phrase matches. Semantic tokens only, so dark /
|
|
34
|
+
* high-contrast schemes (#244) ride through. Soft-empty: no purposes → the
|
|
35
|
+
* `emptyText` note (the data rights still render).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <ConsentManager
|
|
39
|
+
* label="Privacy & consent"
|
|
40
|
+
* items={[
|
|
41
|
+
* { id: "campaign_outreach", label: "Campaign updates", description: "Email about participatory budgeting", active: true },
|
|
42
|
+
* { id: "analytics", label: "Usage analytics", active: false },
|
|
43
|
+
* ]}
|
|
44
|
+
* onToggle={(id, next) => save(id, next)}
|
|
45
|
+
* busyId={savingId}
|
|
46
|
+
* onExport={() => downloadMyData()}
|
|
47
|
+
* confirmPhrase="DELETE"
|
|
48
|
+
* onErase={() => eraseAccount()}
|
|
49
|
+
* />
|
|
50
|
+
*/
|
|
51
|
+
declare const ConsentManager: import("svelte").Component<{
|
|
52
|
+
items?: any[];
|
|
53
|
+
label?: string;
|
|
54
|
+
emptyText?: string;
|
|
55
|
+
onToggle?: any;
|
|
56
|
+
busyId?: any;
|
|
57
|
+
dataRightsLabel?: string;
|
|
58
|
+
exportLabel?: string;
|
|
59
|
+
exportHelp?: string;
|
|
60
|
+
onExport?: any;
|
|
61
|
+
exporting?: boolean;
|
|
62
|
+
eraseLabel?: string;
|
|
63
|
+
erasePrompt?: string;
|
|
64
|
+
confirmPhrase?: string;
|
|
65
|
+
confirmInputLabel?: string;
|
|
66
|
+
confirmButtonLabel?: string;
|
|
67
|
+
cancelLabel?: string;
|
|
68
|
+
onErase?: any;
|
|
69
|
+
erasing?: boolean;
|
|
70
|
+
class?: string;
|
|
71
|
+
} & Record<string, any>, {}, "">;
|
|
72
|
+
type $$ComponentProps = {
|
|
73
|
+
items?: any[];
|
|
74
|
+
label?: string;
|
|
75
|
+
emptyText?: string;
|
|
76
|
+
onToggle?: any;
|
|
77
|
+
busyId?: any;
|
|
78
|
+
dataRightsLabel?: string;
|
|
79
|
+
exportLabel?: string;
|
|
80
|
+
exportHelp?: string;
|
|
81
|
+
onExport?: any;
|
|
82
|
+
exporting?: boolean;
|
|
83
|
+
eraseLabel?: string;
|
|
84
|
+
erasePrompt?: string;
|
|
85
|
+
confirmPhrase?: string;
|
|
86
|
+
confirmInputLabel?: string;
|
|
87
|
+
confirmButtonLabel?: string;
|
|
88
|
+
cancelLabel?: string;
|
|
89
|
+
onErase?: any;
|
|
90
|
+
erasing?: boolean;
|
|
91
|
+
class?: string;
|
|
92
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ServiceFlow
|
|
3
|
+
|
|
4
|
+
The chrome for a multi-step submit journey (a "service flow"): a step
|
|
5
|
+
indicator, a progress bar, an error-summary region, the active step's body,
|
|
6
|
+
and the prev / next / submit navigation. Vertical-agnostic — it renders
|
|
7
|
+
whatever the consumer puts in the `children` snippet for the active step and
|
|
8
|
+
never knows what an "eligibility check" or "proposal" is. The consumer owns
|
|
9
|
+
the wizard STATE (which step, the collected answers) and the network; this
|
|
10
|
+
shell owns the layout, the accessible step semantics, and focus management.
|
|
11
|
+
|
|
12
|
+
Controlled: pass `current` (0-based active step) and the `steps` labels;
|
|
13
|
+
drive it with `onPrev` / `onNext` (advance) and `onSubmit` (the final step's
|
|
14
|
+
primary action). `canProceed=false` disables the primary button (e.g. an
|
|
15
|
+
eligibility gate failed or the step is invalid); `busy=true` shows it loading
|
|
16
|
+
and locks the nav (a dry-run or the submit is in flight).
|
|
17
|
+
|
|
18
|
+
Accessibility (WCAG): a `<section>` region named by `label`; a polite live
|
|
19
|
+
region announces "Step X of Y"; on every step change focus moves to the step
|
|
20
|
+
body (or, if the consumer surfaces `errors`, to the error summary — an
|
|
21
|
+
`role="alert"` list the citizen lands on so the reason is read first). The
|
|
22
|
+
error summary, the step nav buttons, and the progress bar all ride semantic
|
|
23
|
+
tokens, so dark / high-contrast schemes (#244) work unchanged.
|
|
24
|
+
|
|
25
|
+
@example
|
|
26
|
+
<ServiceFlow
|
|
27
|
+
label="Submit a proposal"
|
|
28
|
+
steps={[{ label: "Eligibility" }, { label: "Your idea" }, { label: "Review" }]}
|
|
29
|
+
current={step}
|
|
30
|
+
canProceed={eligible}
|
|
31
|
+
busy={checking}
|
|
32
|
+
errors={violations}
|
|
33
|
+
onPrev={() => step--}
|
|
34
|
+
onNext={() => step++}
|
|
35
|
+
onSubmit={() => submit()}
|
|
36
|
+
>
|
|
37
|
+
{@render activeStepBody()}
|
|
38
|
+
</ServiceFlow>
|
|
39
|
+
-->
|
|
40
|
+
<script module>
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{ label: string }} FlowStep
|
|
43
|
+
*/
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
import Stepper from "./Stepper.svelte";
|
|
48
|
+
import Progress from "./Progress.svelte";
|
|
49
|
+
import Button from "./Button.svelte";
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
/** @type {FlowStep[]} Ordered step labels for the indicator. */
|
|
53
|
+
steps = [],
|
|
54
|
+
/** @type {number} The active step, 0-based. */
|
|
55
|
+
current = 0,
|
|
56
|
+
/** @type {string} Accessible name for the flow region (localize it). */
|
|
57
|
+
label = "Step-by-step form",
|
|
58
|
+
/** @type {boolean} Whether the primary (next/submit) button is enabled. */
|
|
59
|
+
canProceed = true,
|
|
60
|
+
/** @type {boolean} A step action is in flight — locks nav, loads the button. */
|
|
61
|
+
busy = false,
|
|
62
|
+
/** @type {string[]} Blocking messages for the error summary (localize them). */
|
|
63
|
+
errors = [],
|
|
64
|
+
|
|
65
|
+
/** @type {string} Label for the error-summary heading (localize it). */
|
|
66
|
+
errorSummaryLabel = "There is a problem",
|
|
67
|
+
/** @type {string} Back-button label (localize it). */
|
|
68
|
+
prevLabel = "Back",
|
|
69
|
+
/** @type {string} Advance-button label (localize it). */
|
|
70
|
+
nextLabel = "Continue",
|
|
71
|
+
/** @type {string} Final-step primary label (localize it). */
|
|
72
|
+
submitLabel = "Submit",
|
|
73
|
+
/**
|
|
74
|
+
* @type {(stepNumber: number, total: number) => string}
|
|
75
|
+
* Builds the "Step X of Y" announcement (localize it).
|
|
76
|
+
*/
|
|
77
|
+
progressLabel = (n, total) => `Step ${n} of ${total}`,
|
|
78
|
+
|
|
79
|
+
/** @type {(() => void) | undefined} Go to the previous step. */
|
|
80
|
+
onPrev = undefined,
|
|
81
|
+
/** @type {(() => void) | undefined} Advance to the next step. */
|
|
82
|
+
onNext = undefined,
|
|
83
|
+
/** @type {(() => void) | undefined} Run the final step's primary action. */
|
|
84
|
+
onSubmit = undefined,
|
|
85
|
+
|
|
86
|
+
/** @type {import('svelte').Snippet | undefined} The active step's body. */
|
|
87
|
+
children = undefined,
|
|
88
|
+
|
|
89
|
+
/** @type {string} */
|
|
90
|
+
class: className = "",
|
|
91
|
+
...rest
|
|
92
|
+
} = $props();
|
|
93
|
+
|
|
94
|
+
const total = $derived(steps.length || 1);
|
|
95
|
+
const isFirst = $derived(current <= 0);
|
|
96
|
+
const isLast = $derived(current >= total - 1);
|
|
97
|
+
const hasErrors = $derived(errors.length > 0);
|
|
98
|
+
|
|
99
|
+
/** Map the flat step labels to the Stepper's status model. */
|
|
100
|
+
const indicatorSteps = $derived(
|
|
101
|
+
steps.map((s, i) => ({
|
|
102
|
+
label: s.label,
|
|
103
|
+
status:
|
|
104
|
+
i < current ? "complete" : i === current ? "active" : "upcoming",
|
|
105
|
+
})),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/** @type {HTMLElement | undefined} */
|
|
109
|
+
let bodyRef = $state(undefined);
|
|
110
|
+
/** @type {HTMLElement | undefined} */
|
|
111
|
+
let errorRef = $state(undefined);
|
|
112
|
+
|
|
113
|
+
// Focus management: after the first render, every step change (or a newly
|
|
114
|
+
// surfaced error) moves focus so a keyboard / screen-reader user is taken to
|
|
115
|
+
// the right place — the error summary when blocked, otherwise the step body.
|
|
116
|
+
// The initial mount is skipped so the page doesn't steal focus on load.
|
|
117
|
+
let mounted = false;
|
|
118
|
+
$effect(() => {
|
|
119
|
+
// Re-run when the step or the error state changes.
|
|
120
|
+
void current;
|
|
121
|
+
void hasErrors;
|
|
122
|
+
if (!mounted) {
|
|
123
|
+
mounted = true;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const target = hasErrors ? errorRef : bodyRef;
|
|
127
|
+
target?.focus();
|
|
128
|
+
});
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<section class="service-flow {className}" aria-label={label} {...rest}>
|
|
132
|
+
{#if steps.length > 1}
|
|
133
|
+
<Stepper steps={indicatorSteps} class="service-flow-stepper" />
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
<Progress
|
|
137
|
+
value={current + 1}
|
|
138
|
+
max={total}
|
|
139
|
+
class="service-flow-progress"
|
|
140
|
+
aria-label={progressLabel(current + 1, total)}
|
|
141
|
+
/>
|
|
142
|
+
<p class="service-flow-progress-text" aria-live="polite">
|
|
143
|
+
{progressLabel(current + 1, total)}
|
|
144
|
+
</p>
|
|
145
|
+
|
|
146
|
+
{#if hasErrors}
|
|
147
|
+
<div
|
|
148
|
+
class="service-flow-errors"
|
|
149
|
+
role="alert"
|
|
150
|
+
tabindex="-1"
|
|
151
|
+
bind:this={errorRef}
|
|
152
|
+
>
|
|
153
|
+
<h2 class="service-flow-errors-heading">{errorSummaryLabel}</h2>
|
|
154
|
+
<ul class="service-flow-errors-list">
|
|
155
|
+
{#each errors as message}
|
|
156
|
+
<li>{message}</li>
|
|
157
|
+
{/each}
|
|
158
|
+
</ul>
|
|
159
|
+
</div>
|
|
160
|
+
{/if}
|
|
161
|
+
|
|
162
|
+
<div class="service-flow-body" tabindex="-1" bind:this={bodyRef}>
|
|
163
|
+
{@render children?.()}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div class="service-flow-nav">
|
|
167
|
+
{#if !isFirst}
|
|
168
|
+
<Button variant="ghost" disabled={busy} onclick={() => onPrev?.()}>
|
|
169
|
+
{prevLabel}
|
|
170
|
+
</Button>
|
|
171
|
+
{/if}
|
|
172
|
+
{#if isLast}
|
|
173
|
+
<Button
|
|
174
|
+
variant="primary"
|
|
175
|
+
loading={busy}
|
|
176
|
+
disabled={!canProceed || busy}
|
|
177
|
+
onclick={() => onSubmit?.()}
|
|
178
|
+
>
|
|
179
|
+
{submitLabel}
|
|
180
|
+
</Button>
|
|
181
|
+
{:else}
|
|
182
|
+
<Button
|
|
183
|
+
variant="primary"
|
|
184
|
+
loading={busy}
|
|
185
|
+
disabled={!canProceed || busy}
|
|
186
|
+
onclick={() => onNext?.()}
|
|
187
|
+
>
|
|
188
|
+
{nextLabel}
|
|
189
|
+
</Button>
|
|
190
|
+
{/if}
|
|
191
|
+
</div>
|
|
192
|
+
</section>
|
|
193
|
+
|
|
194
|
+
<style>
|
|
195
|
+
.service-flow {
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
gap: var(--space-lg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.service-flow-progress-text {
|
|
202
|
+
font-family: var(--type-body-sm-font);
|
|
203
|
+
font-size: var(--type-body-sm-size);
|
|
204
|
+
color: var(--color-text-muted);
|
|
205
|
+
margin: calc(-1 * var(--space-sm)) 0 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.service-flow-errors {
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
gap: var(--space-sm);
|
|
212
|
+
padding: var(--space-md);
|
|
213
|
+
border: 1px solid var(--color-destructive);
|
|
214
|
+
border-radius: var(--radius-md);
|
|
215
|
+
background: var(--color-destructive-subtle);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* The alert region is programmatically focusable but shows no focus ring. */
|
|
219
|
+
.service-flow-errors:focus {
|
|
220
|
+
outline: none;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.service-flow-errors-heading {
|
|
224
|
+
font-family: var(--type-heading-sm-font);
|
|
225
|
+
font-size: var(--type-heading-sm-size);
|
|
226
|
+
color: var(--color-text);
|
|
227
|
+
margin: 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.service-flow-errors-list {
|
|
231
|
+
margin: 0;
|
|
232
|
+
padding-left: var(--space-lg);
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
gap: var(--space-2xs);
|
|
236
|
+
font-family: var(--type-body-font);
|
|
237
|
+
font-size: var(--type-body-size);
|
|
238
|
+
color: var(--color-text);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.service-flow-body {
|
|
242
|
+
display: flex;
|
|
243
|
+
flex-direction: column;
|
|
244
|
+
gap: var(--space-md);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.service-flow-body:focus {
|
|
248
|
+
outline: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.service-flow-nav {
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-wrap: wrap;
|
|
254
|
+
justify-content: space-between;
|
|
255
|
+
gap: var(--space-sm);
|
|
256
|
+
padding-top: var(--space-md);
|
|
257
|
+
border-top: 1px solid var(--color-border);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* When only the primary button shows (first step), keep it right-aligned. */
|
|
261
|
+
.service-flow-nav:has(> :only-child) {
|
|
262
|
+
justify-content: flex-end;
|
|
263
|
+
}
|
|
264
|
+
</style>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export default ServiceFlow;
|
|
2
|
+
export type FlowStep = {
|
|
3
|
+
label: string;
|
|
4
|
+
};
|
|
5
|
+
type ServiceFlow = {
|
|
6
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
7
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* ServiceFlow
|
|
11
|
+
*
|
|
12
|
+
* The chrome for a multi-step submit journey (a "service flow"): a step
|
|
13
|
+
* indicator, a progress bar, an error-summary region, the active step's body,
|
|
14
|
+
* and the prev / next / submit navigation. Vertical-agnostic — it renders
|
|
15
|
+
* whatever the consumer puts in the `children` snippet for the active step and
|
|
16
|
+
* never knows what an "eligibility check" or "proposal" is. The consumer owns
|
|
17
|
+
* the wizard STATE (which step, the collected answers) and the network; this
|
|
18
|
+
* shell owns the layout, the accessible step semantics, and focus management.
|
|
19
|
+
*
|
|
20
|
+
* Controlled: pass `current` (0-based active step) and the `steps` labels;
|
|
21
|
+
* drive it with `onPrev` / `onNext` (advance) and `onSubmit` (the final step's
|
|
22
|
+
* primary action). `canProceed=false` disables the primary button (e.g. an
|
|
23
|
+
* eligibility gate failed or the step is invalid); `busy=true` shows it loading
|
|
24
|
+
* and locks the nav (a dry-run or the submit is in flight).
|
|
25
|
+
*
|
|
26
|
+
* Accessibility (WCAG): a `<section>` region named by `label`; a polite live
|
|
27
|
+
* region announces "Step X of Y"; on every step change focus moves to the step
|
|
28
|
+
* body (or, if the consumer surfaces `errors`, to the error summary — an
|
|
29
|
+
* `role="alert"` list the citizen lands on so the reason is read first). The
|
|
30
|
+
* error summary, the step nav buttons, and the progress bar all ride semantic
|
|
31
|
+
* tokens, so dark / high-contrast schemes (#244) work unchanged.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <ServiceFlow
|
|
35
|
+
* label="Submit a proposal"
|
|
36
|
+
* steps={[{ label: "Eligibility" }, { label: "Your idea" }, { label: "Review" }]}
|
|
37
|
+
* current={step}
|
|
38
|
+
* canProceed={eligible}
|
|
39
|
+
* busy={checking}
|
|
40
|
+
* errors={violations}
|
|
41
|
+
* onPrev={() => step--}
|
|
42
|
+
* onNext={() => step++}
|
|
43
|
+
* onSubmit={() => submit()}
|
|
44
|
+
* >
|
|
45
|
+
* {@render activeStepBody()}
|
|
46
|
+
* </ServiceFlow>
|
|
47
|
+
*/
|
|
48
|
+
declare const ServiceFlow: import("svelte").Component<{
|
|
49
|
+
steps?: any[];
|
|
50
|
+
current?: number;
|
|
51
|
+
label?: string;
|
|
52
|
+
canProceed?: boolean;
|
|
53
|
+
busy?: boolean;
|
|
54
|
+
errors?: any[];
|
|
55
|
+
errorSummaryLabel?: string;
|
|
56
|
+
prevLabel?: string;
|
|
57
|
+
nextLabel?: string;
|
|
58
|
+
submitLabel?: string;
|
|
59
|
+
progressLabel?: Function;
|
|
60
|
+
onPrev?: any;
|
|
61
|
+
onNext?: any;
|
|
62
|
+
onSubmit?: any;
|
|
63
|
+
children?: any;
|
|
64
|
+
class?: string;
|
|
65
|
+
} & Record<string, any>, {}, "">;
|
|
66
|
+
type $$ComponentProps = {
|
|
67
|
+
steps?: any[];
|
|
68
|
+
current?: number;
|
|
69
|
+
label?: string;
|
|
70
|
+
canProceed?: boolean;
|
|
71
|
+
busy?: boolean;
|
|
72
|
+
errors?: any[];
|
|
73
|
+
errorSummaryLabel?: string;
|
|
74
|
+
prevLabel?: string;
|
|
75
|
+
nextLabel?: string;
|
|
76
|
+
submitLabel?: string;
|
|
77
|
+
progressLabel?: Function;
|
|
78
|
+
onPrev?: any;
|
|
79
|
+
onNext?: any;
|
|
80
|
+
onSubmit?: any;
|
|
81
|
+
children?: any;
|
|
82
|
+
class?: string;
|
|
83
|
+
} & Record<string, any>;
|
package/components/index.js
CHANGED
|
@@ -93,6 +93,16 @@ export { default as ResultsChart } from "./ResultsChart.svelte";
|
|
|
93
93
|
// Account / preferences (#105 Phase 5) — the citizen account area's
|
|
94
94
|
// notification-preferences panel: a toggle list over a citizen's subscriptions.
|
|
95
95
|
export { default as NotificationPrefs } from "./NotificationPrefs.svelte";
|
|
96
|
+
// Account / RGPD (#105 Phase 5 slice 2) — consent purposes toggle list +
|
|
97
|
+
// data portability (export) + type-to-confirm account erasure.
|
|
98
|
+
export { default as ConsentManager } from "./ConsentManager.svelte";
|
|
99
|
+
|
|
100
|
+
// Service-flow steps (#105 Phase 6) — the multi-step submit journey: a wizard
|
|
101
|
+
// shell (step nav + progress + focus mgmt + error summary), the review-before-
|
|
102
|
+
// submit answer list (WCAG 3.3.4 change links), and the success receipt.
|
|
103
|
+
export { default as ServiceFlow } from "./ServiceFlow.svelte";
|
|
104
|
+
export { default as CheckAnswers } from "./CheckAnswers.svelte";
|
|
105
|
+
export { default as Confirmation } from "./Confirmation.svelte";
|
|
96
106
|
|
|
97
107
|
// Feedback
|
|
98
108
|
export { default as Alert } from "./Alert.svelte";
|