@aiaiai-pt/design-system 0.24.0 → 0.26.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/CallToAction.svelte +180 -0
- package/components/CallToAction.svelte.d.ts +43 -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/FaqList.svelte +159 -0
- package/components/FaqList.svelte.d.ts +36 -0
- package/components/FeatureGrid.svelte +139 -0
- package/components/FeatureGrid.svelte.d.ts +48 -0
- package/components/Hero.svelte +23 -1
- package/components/Hero.svelte.d.ts +7 -0
- package/components/LogoStrip.svelte +116 -0
- package/components/LogoStrip.svelte.d.ts +36 -0
- package/components/MediaGallery.svelte +175 -0
- package/components/MediaGallery.svelte.d.ts +46 -0
- package/components/MediaText.svelte +143 -0
- package/components/MediaText.svelte.d.ts +52 -0
- package/components/ServiceFlow.svelte +264 -0
- package/components/ServiceFlow.svelte.d.ts +83 -0
- package/components/Steps.svelte +137 -0
- package/components/Steps.svelte.d.ts +37 -0
- package/components/Testimonial.svelte +161 -0
- package/components/Testimonial.svelte.d.ts +45 -0
- package/components/index.d.ts +16 -0
- package/components/index.js +19 -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,180 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component CallToAction
|
|
3
|
+
|
|
4
|
+
A conversion band: a heading, optional body, and one or two action buttons.
|
|
5
|
+
The reusable "ready to act?" strip at the foot of landing / campaign / content
|
|
6
|
+
pages. Presentational — heading, body and buttons (label + href) are DATA, so
|
|
7
|
+
it drops into any vertical.
|
|
8
|
+
|
|
9
|
+
`variant="accent"` fills the band with the brand accent (text flips to
|
|
10
|
+
`--color-text-on-accent`); `"surface"` (default) is a quiet bordered band.
|
|
11
|
+
|
|
12
|
+
@example
|
|
13
|
+
<CallToAction
|
|
14
|
+
heading="Tem uma ideia para o seu município?"
|
|
15
|
+
body="Submeta a sua proposta ao Orçamento Participativo."
|
|
16
|
+
primary={{ label: "Submeter proposta", href: "/propor" }}
|
|
17
|
+
secondary={{ label: "Ver propostas", href: "/mapa" }}
|
|
18
|
+
variant="accent"
|
|
19
|
+
/>
|
|
20
|
+
-->
|
|
21
|
+
<script>
|
|
22
|
+
import Button from "./Button.svelte";
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
/** @type {string} */
|
|
26
|
+
heading = "",
|
|
27
|
+
/** @type {string} */
|
|
28
|
+
body = "",
|
|
29
|
+
/** @type {2 | 3} Heading level for `heading`. */
|
|
30
|
+
headingLevel = 2,
|
|
31
|
+
/** @type {{ label?: string, href?: string } | undefined} Primary CTA. */
|
|
32
|
+
primary = undefined,
|
|
33
|
+
/** @type {{ label?: string, href?: string } | undefined} Secondary CTA. */
|
|
34
|
+
secondary = undefined,
|
|
35
|
+
/** @type {'surface' | 'accent'} Band fill. */
|
|
36
|
+
variant = "surface",
|
|
37
|
+
/** @type {string} */
|
|
38
|
+
class: className = "",
|
|
39
|
+
...rest
|
|
40
|
+
} = $props();
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<section class="cta cta-{variant} {className}" {...rest}>
|
|
44
|
+
<div class="cta-inner">
|
|
45
|
+
{#if heading}
|
|
46
|
+
<svelte:element this={`h${headingLevel}`} class="cta-heading"
|
|
47
|
+
>{heading}</svelte:element
|
|
48
|
+
>
|
|
49
|
+
{/if}
|
|
50
|
+
{#if body}<p class="cta-body">{body}</p>{/if}
|
|
51
|
+
{#if (primary && primary.label) || (secondary && secondary.label)}
|
|
52
|
+
<div class="cta-actions">
|
|
53
|
+
{#if variant === "accent"}
|
|
54
|
+
<!-- On an accent (brand-coloured) fill the DS button variants don't
|
|
55
|
+
guarantee contrast (the theme's `secondary`/`ghost` text/bg sit
|
|
56
|
+
too close to the accent). Render explicit INVERTED controls whose
|
|
57
|
+
colours are the on-accent/accent token pair — the same contrast
|
|
58
|
+
ratio as a primary button, just flipped. -->
|
|
59
|
+
{#if primary && primary.label}
|
|
60
|
+
<a class="cta-btn-inverted" href={primary.href || undefined}
|
|
61
|
+
>{primary.label}</a
|
|
62
|
+
>
|
|
63
|
+
{/if}
|
|
64
|
+
{#if secondary && secondary.label}
|
|
65
|
+
<a class="cta-link-on-accent" href={secondary.href || undefined}
|
|
66
|
+
>{secondary.label}</a
|
|
67
|
+
>
|
|
68
|
+
{/if}
|
|
69
|
+
{:else}
|
|
70
|
+
{#if primary && primary.label}
|
|
71
|
+
<Button href={primary.href || undefined} variant="primary"
|
|
72
|
+
>{primary.label}</Button
|
|
73
|
+
>
|
|
74
|
+
{/if}
|
|
75
|
+
{#if secondary && secondary.label}
|
|
76
|
+
<Button href={secondary.href || undefined} variant="ghost"
|
|
77
|
+
>{secondary.label}</Button
|
|
78
|
+
>
|
|
79
|
+
{/if}
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.cta {
|
|
88
|
+
border-radius: var(--card-radius);
|
|
89
|
+
overflow-wrap: break-word;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.cta-surface {
|
|
93
|
+
background: var(--color-surface-secondary);
|
|
94
|
+
border: 1px solid var(--color-border);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.cta-accent {
|
|
98
|
+
background: var(--color-accent);
|
|
99
|
+
color: var(--color-text-on-accent);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.cta-inner {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: var(--space-md);
|
|
106
|
+
align-items: flex-start;
|
|
107
|
+
max-width: var(--content-width-wide);
|
|
108
|
+
margin-inline: auto;
|
|
109
|
+
padding: var(--space-2xl) var(--content-padding-x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.cta-heading {
|
|
113
|
+
margin: 0;
|
|
114
|
+
font-family: var(--type-heading-font);
|
|
115
|
+
font-size: var(--type-heading-size);
|
|
116
|
+
line-height: var(--type-heading-leading);
|
|
117
|
+
color: inherit;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.cta-surface .cta-heading {
|
|
121
|
+
color: var(--color-text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cta-body {
|
|
125
|
+
margin: 0;
|
|
126
|
+
max-width: var(--content-width-narrow);
|
|
127
|
+
font-family: var(--type-body-font);
|
|
128
|
+
font-size: var(--type-body-size);
|
|
129
|
+
line-height: var(--type-body-leading);
|
|
130
|
+
color: inherit;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.cta-surface .cta-body {
|
|
134
|
+
color: var(--color-text-secondary);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.cta-actions {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-wrap: wrap;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: var(--space-md);
|
|
142
|
+
margin-top: var(--space-xs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Inverted solid button for the accent band: the on-accent surface as the
|
|
146
|
+
fill, the accent as the text — the primary-button token pair, flipped, so
|
|
147
|
+
it carries the same contrast guarantee against the coloured band. */
|
|
148
|
+
.cta-btn-inverted {
|
|
149
|
+
display: inline-flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
height: var(--button-md-height);
|
|
152
|
+
padding-inline: var(--button-md-padding-x);
|
|
153
|
+
border-radius: var(--button-radius);
|
|
154
|
+
background: var(--color-text-on-accent);
|
|
155
|
+
color: var(--color-accent);
|
|
156
|
+
font-family: var(--button-font);
|
|
157
|
+
font-size: var(--button-md-font-size);
|
|
158
|
+
letter-spacing: var(--button-tracking);
|
|
159
|
+
text-decoration: none;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.cta-btn-inverted:focus-visible {
|
|
164
|
+
outline: var(--focus-ring-width) solid var(--color-text-on-accent);
|
|
165
|
+
outline-offset: var(--focus-ring-offset);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.cta-link-on-accent {
|
|
169
|
+
color: var(--color-text-on-accent);
|
|
170
|
+
font-family: var(--type-body-font);
|
|
171
|
+
font-size: var(--type-body-size);
|
|
172
|
+
text-decoration: underline;
|
|
173
|
+
text-underline-offset: 0.2em;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.cta-link-on-accent:focus-visible {
|
|
177
|
+
outline: var(--focus-ring-width) solid var(--color-text-on-accent);
|
|
178
|
+
outline-offset: var(--focus-ring-offset);
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export default CallToAction;
|
|
2
|
+
type CallToAction = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* CallToAction
|
|
8
|
+
*
|
|
9
|
+
* A conversion band: a heading, optional body, and one or two action buttons.
|
|
10
|
+
* The reusable "ready to act?" strip at the foot of landing / campaign / content
|
|
11
|
+
* pages. Presentational — heading, body and buttons (label + href) are DATA, so
|
|
12
|
+
* it drops into any vertical.
|
|
13
|
+
*
|
|
14
|
+
* `variant="accent"` fills the band with the brand accent (text flips to
|
|
15
|
+
* `--color-text-on-accent`); `"surface"` (default) is a quiet bordered band.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* <CallToAction
|
|
19
|
+
* heading="Tem uma ideia para o seu município?"
|
|
20
|
+
* body="Submeta a sua proposta ao Orçamento Participativo."
|
|
21
|
+
* primary={{ label: "Submeter proposta", href: "/propor" }}
|
|
22
|
+
* secondary={{ label: "Ver propostas", href: "/mapa" }}
|
|
23
|
+
* variant="accent"
|
|
24
|
+
* />
|
|
25
|
+
*/
|
|
26
|
+
declare const CallToAction: import("svelte").Component<{
|
|
27
|
+
heading?: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
headingLevel?: number;
|
|
30
|
+
primary?: any;
|
|
31
|
+
secondary?: any;
|
|
32
|
+
variant?: string;
|
|
33
|
+
class?: string;
|
|
34
|
+
} & Record<string, any>, {}, "">;
|
|
35
|
+
type $$ComponentProps = {
|
|
36
|
+
heading?: string;
|
|
37
|
+
body?: string;
|
|
38
|
+
headingLevel?: number;
|
|
39
|
+
primary?: any;
|
|
40
|
+
secondary?: any;
|
|
41
|
+
variant?: string;
|
|
42
|
+
class?: string;
|
|
43
|
+
} & Record<string, any>;
|
|
@@ -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>;
|