@aiaiai-pt/design-system 0.8.3 → 0.9.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/AppFrame.svelte +75 -0
- package/components/AppFrame.svelte.d.ts +42 -0
- package/components/ContentBlock.svelte +100 -0
- package/components/ContentBlock.svelte.d.ts +37 -0
- package/components/Hero.svelte +87 -0
- package/components/Hero.svelte.d.ts +37 -0
- package/components/Link.svelte +100 -0
- package/components/Link.svelte.d.ts +46 -0
- package/components/ServiceNavigation.svelte +160 -0
- package/components/ServiceNavigation.svelte.d.ts +49 -0
- package/components/SiteFooter.svelte +83 -0
- package/components/SiteFooter.svelte.d.ts +37 -0
- package/components/SiteHeader.svelte +90 -0
- package/components/SiteHeader.svelte.d.ts +36 -0
- package/components/SkipLink.svelte +63 -0
- package/components/SkipLink.svelte.d.ts +33 -0
- package/components/StatusTimeline.svelte +193 -0
- package/components/StatusTimeline.svelte.d.ts +44 -0
- package/components/ValueSourcePicker.svelte +49 -25
- package/components/VotingWidget.svelte +292 -0
- package/components/VotingWidget.svelte.d.ts +80 -0
- package/components/WidgetGrid.svelte +83 -0
- package/components/WidgetGrid.svelte.d.ts +42 -0
- package/components/index.js +13 -0
- package/package.json +1 -1
- package/tokens/themes/valongo.css +44 -0
|
@@ -81,6 +81,15 @@
|
|
|
81
81
|
"function",
|
|
82
82
|
"expression",
|
|
83
83
|
],
|
|
84
|
+
/**
|
|
85
|
+
* Mode pre-selected when `value === null`, so the operator lands
|
|
86
|
+
* on an editable affordance instead of a "Pick a source"
|
|
87
|
+
* placeholder. The bound `value` stays `null` until the operator
|
|
88
|
+
* interacts — form dirty-tracking and consumer-side null intent
|
|
89
|
+
* survive. The default is forbidden if it isn't in `allowed`.
|
|
90
|
+
* @type {ValueSourceMode | null}
|
|
91
|
+
*/
|
|
92
|
+
defaultMode = null,
|
|
84
93
|
/** @type {ValueSourceContext} */
|
|
85
94
|
context = {},
|
|
86
95
|
/** @type {string | undefined} */
|
|
@@ -137,6 +146,19 @@
|
|
|
137
146
|
currentMode !== null && !allowed.includes(currentMode),
|
|
138
147
|
);
|
|
139
148
|
|
|
149
|
+
// Effective view of the picker — uses `defaultMode` as a fallback
|
|
150
|
+
// when there's no stored value, so the operator sees a mode +
|
|
151
|
+
// affordance pre-selected. Read-only: `value` stays null until the
|
|
152
|
+
// operator interacts. Forbidden defaults silently fall back to the
|
|
153
|
+
// null placeholder rather than rendering a mode that isn't allowed.
|
|
154
|
+
const effectiveValue = $derived.by(() => {
|
|
155
|
+
if (value !== null) return value;
|
|
156
|
+
if (defaultMode === null) return null;
|
|
157
|
+
if (!allowed.includes(defaultMode)) return null;
|
|
158
|
+
return makeBlankForMode(defaultMode);
|
|
159
|
+
});
|
|
160
|
+
const effectiveMode = $derived(effectiveValue?.mode ?? null);
|
|
161
|
+
|
|
140
162
|
function emit(next) {
|
|
141
163
|
value = next;
|
|
142
164
|
onchange?.(next);
|
|
@@ -144,7 +166,9 @@
|
|
|
144
166
|
|
|
145
167
|
function pickMode(/** @type {string} */ next) {
|
|
146
168
|
const mode = /** @type {ValueSourceMode} */ (next);
|
|
147
|
-
|
|
169
|
+
// Compare against the effective mode so re-picking the
|
|
170
|
+
// pre-selected default is a no-op rather than an emit.
|
|
171
|
+
if (mode === effectiveMode) return;
|
|
148
172
|
emit(makeBlankForMode(mode));
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -374,8 +398,8 @@
|
|
|
374
398
|
<Select
|
|
375
399
|
id={`${groupId}-mode`}
|
|
376
400
|
size="sm"
|
|
377
|
-
placeholder={
|
|
378
|
-
value={
|
|
401
|
+
placeholder={effectiveValue === null ? "Pick a source" : undefined}
|
|
402
|
+
value={effectiveMode ?? ""}
|
|
379
403
|
options={modeOptions}
|
|
380
404
|
onchange={pickMode}
|
|
381
405
|
{disabled}
|
|
@@ -388,90 +412,90 @@
|
|
|
388
412
|
<Badge variant="error">no longer allowed: {MODE_LABEL[value.mode]}</Badge>
|
|
389
413
|
<Button variant="ghost" size="sm" onclick={clear}>Clear</Button>
|
|
390
414
|
</div>
|
|
391
|
-
{:else if
|
|
415
|
+
{:else if effectiveValue === null}
|
|
392
416
|
<span class="vsp-placeholder">{MODE_DESCRIPTION[allowed[0]] ?? ""}</span>
|
|
393
|
-
{:else if
|
|
417
|
+
{:else if effectiveValue.mode === "literal"}
|
|
394
418
|
<Input
|
|
395
419
|
size="sm"
|
|
396
420
|
placeholder={literalPlaceholder(expectedType)}
|
|
397
|
-
value={String(
|
|
421
|
+
value={String(effectiveValue.value ?? "")}
|
|
398
422
|
oninput={(e) => emit({ mode: "literal", value: coerceLiteral(/** @type {HTMLInputElement} */ (e.target).value, expectedType) })}
|
|
399
423
|
{disabled}
|
|
400
424
|
/>
|
|
401
|
-
{:else if
|
|
425
|
+
{:else if effectiveValue.mode === "parameter"}
|
|
402
426
|
<Combobox
|
|
403
427
|
size="sm"
|
|
404
428
|
placeholder="Pick a parameter"
|
|
405
429
|
items={paramOptions()}
|
|
406
|
-
value={
|
|
430
|
+
value={effectiveValue.key}
|
|
407
431
|
onchange={(k) => emit({ mode: "parameter", key: k })}
|
|
408
432
|
{disabled}
|
|
409
433
|
/>
|
|
410
|
-
{:else if
|
|
434
|
+
{:else if effectiveValue.mode === "entity-field"}
|
|
411
435
|
<Combobox
|
|
412
436
|
size="sm"
|
|
413
437
|
placeholder="Pick a field"
|
|
414
438
|
items={entityFieldOptions()}
|
|
415
|
-
value={
|
|
439
|
+
value={effectiveValue.field}
|
|
416
440
|
onchange={(f) => emit({ mode: "entity-field", field: f })}
|
|
417
441
|
{disabled}
|
|
418
442
|
/>
|
|
419
|
-
{:else if
|
|
443
|
+
{:else if effectiveValue.mode === "user-field"}
|
|
420
444
|
<Combobox
|
|
421
445
|
size="sm"
|
|
422
446
|
placeholder="Pick a user claim"
|
|
423
447
|
items={userFieldOptions()}
|
|
424
|
-
value={
|
|
448
|
+
value={effectiveValue.key}
|
|
425
449
|
onchange={(k) => emit({ mode: "user-field", key: k })}
|
|
426
450
|
{disabled}
|
|
427
451
|
/>
|
|
428
|
-
{:else if
|
|
452
|
+
{:else if effectiveValue.mode === "now"}
|
|
429
453
|
<Badge variant="info">$now — current UTC timestamp</Badge>
|
|
430
|
-
{:else if
|
|
454
|
+
{:else if effectiveValue.mode === "source-id"}
|
|
431
455
|
<Badge variant="info">$source.id — id of the entity being acted upon</Badge>
|
|
432
|
-
{:else if
|
|
456
|
+
{:else if effectiveValue.mode === "config-list"}
|
|
433
457
|
<Combobox
|
|
434
458
|
size="sm"
|
|
435
459
|
placeholder="Pick a config type"
|
|
436
460
|
items={configTypeOptions()}
|
|
437
|
-
value={
|
|
461
|
+
value={effectiveValue.configType}
|
|
438
462
|
onchange={(c) => emit({ mode: "config-list", configType: c })}
|
|
439
463
|
{disabled}
|
|
440
464
|
/>
|
|
441
465
|
<Badge variant="neutral">list</Badge>
|
|
442
|
-
{:else if
|
|
466
|
+
{:else if effectiveValue.mode === "expression"}
|
|
443
467
|
<Input
|
|
444
468
|
size="sm"
|
|
445
469
|
placeholder="$entity.X + 1"
|
|
446
|
-
value={
|
|
470
|
+
value={effectiveValue.expr}
|
|
447
471
|
oninput={(e) => emit({ mode: "expression", expr: /** @type {HTMLInputElement} */ (e.target).value })}
|
|
448
472
|
{disabled}
|
|
449
473
|
/>
|
|
450
|
-
{:else if
|
|
474
|
+
{:else if effectiveValue.mode === "created-field"}
|
|
451
475
|
<div class="vsp-inline">
|
|
452
476
|
<Combobox
|
|
453
477
|
size="sm"
|
|
454
478
|
placeholder="prior create"
|
|
455
479
|
items={priorCreateOptions()}
|
|
456
|
-
value={String(
|
|
457
|
-
onchange={(i) => emit({ mode: "created-field", index: Number(i), field:
|
|
480
|
+
value={String(effectiveValue.index)}
|
|
481
|
+
onchange={(i) => emit({ mode: "created-field", index: Number(i), field: /** @type {{mode:'created-field',index:number,field:string}} */ (effectiveValue).field })}
|
|
458
482
|
{disabled}
|
|
459
483
|
/>
|
|
460
484
|
<Combobox
|
|
461
485
|
size="sm"
|
|
462
486
|
placeholder="field"
|
|
463
487
|
items={priorCreateFieldOptions()}
|
|
464
|
-
value={
|
|
465
|
-
onchange={(f) => emit({ mode: "created-field", index:
|
|
488
|
+
value={effectiveValue.field}
|
|
489
|
+
onchange={(f) => emit({ mode: "created-field", index: /** @type {{mode:'created-field',index:number,field:string}} */ (effectiveValue).index, field: f })}
|
|
466
490
|
{disabled}
|
|
467
491
|
/>
|
|
468
492
|
</div>
|
|
469
|
-
{:else if
|
|
493
|
+
{:else if effectiveValue.mode === "function"}
|
|
470
494
|
<Combobox
|
|
471
495
|
size="sm"
|
|
472
496
|
placeholder="Pick a function"
|
|
473
497
|
items={functionOptions()}
|
|
474
|
-
value={
|
|
498
|
+
value={effectiveValue.name}
|
|
475
499
|
onchange={(n) => emit({ mode: "function", name: n, args: {} })}
|
|
476
500
|
{disabled}
|
|
477
501
|
/>
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component VotingWidget
|
|
3
|
+
|
|
4
|
+
Participation vote panel — the Valongo V10 participation-grid primitive
|
|
5
|
+
(participatory budget, consultations, local polls). One question, a radio
|
|
6
|
+
group of options, a submit path, and a pseudonymised receipt after voting.
|
|
7
|
+
Presentational: it emits `onsubmit(optionId)`; the portal wires that to the
|
|
8
|
+
BFF public submit surface (`POST /{app}/public/submit`, voting placement —
|
|
9
|
+
#70 M4), and renders the bot-protection challenge into the `captcha` slot.
|
|
10
|
+
|
|
11
|
+
States (mutually exclusive vote-ability):
|
|
12
|
+
- open — radio group + submit (the default)
|
|
13
|
+
- submitted — confirmation + the `receipt` slot, voting locked
|
|
14
|
+
- closed — voting period over; results only
|
|
15
|
+
- disabled — not eligible (e.g. anonymous where login is required);
|
|
16
|
+
options shown read-only with an announced `disabledReason`
|
|
17
|
+
Pass `showResults` to render the tally as labelled bars (typically once
|
|
18
|
+
`closed` or `submitted`).
|
|
19
|
+
|
|
20
|
+
Accessibility:
|
|
21
|
+
- `<fieldset>` + `<legend>` (the question) groups the options as a native
|
|
22
|
+
radio group — arrow-key navigation and "n of m" come for free.
|
|
23
|
+
- Submit is disabled until an option is chosen; the confirmation + receipt
|
|
24
|
+
land in an `aria-live="polite"` region so a screen reader announces them.
|
|
25
|
+
- Result bars are `aria-hidden`; each option's percentage is in its visible
|
|
26
|
+
text, so the meaning isn't colour/width-only.
|
|
27
|
+
- Radios keep a visible focus ring; reduced-motion disables the bar fill.
|
|
28
|
+
|
|
29
|
+
@example
|
|
30
|
+
<VotingWidget
|
|
31
|
+
question="Which project should Valongo fund first?"
|
|
32
|
+
name="pb-2026"
|
|
33
|
+
options={[{ id: "a", label: "Riverside path" }, { id: "b", label: "School playground" }]}
|
|
34
|
+
bind:selected
|
|
35
|
+
onsubmit={(id) => postVote(id)}
|
|
36
|
+
>
|
|
37
|
+
{#snippet captcha()}<TurnstileWidget /> {/snippet}
|
|
38
|
+
</VotingWidget>
|
|
39
|
+
-->
|
|
40
|
+
<script>
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{ id: string, label: string, votes?: number }} VoteOption
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
/** @type {string} The question (rendered as the fieldset legend). */
|
|
47
|
+
question = "",
|
|
48
|
+
/** @type {VoteOption[]} */
|
|
49
|
+
options = [],
|
|
50
|
+
/** @type {string | undefined} Bindable selected option id. */
|
|
51
|
+
selected = $bindable(undefined),
|
|
52
|
+
/** @type {string} Radio-group name — unique per widget on the page. */
|
|
53
|
+
name = "vote",
|
|
54
|
+
/** @type {boolean} Voting period is over. */
|
|
55
|
+
closed = false,
|
|
56
|
+
/** @type {boolean} This citizen has already voted. */
|
|
57
|
+
submitted = false,
|
|
58
|
+
/** @type {boolean} Voting unavailable (e.g. not eligible / login required). */
|
|
59
|
+
disabled = false,
|
|
60
|
+
/** @type {boolean} Render the tally as labelled bars. */
|
|
61
|
+
showResults = false,
|
|
62
|
+
/** @type {string} Submit button text (localize it). */
|
|
63
|
+
submitLabel = "Submit vote",
|
|
64
|
+
/** @type {string} Heading shown in the submitted state (localize it). */
|
|
65
|
+
submittedLabel = "Your vote has been recorded.",
|
|
66
|
+
/** @type {string} Notice shown in the closed state (localize it). */
|
|
67
|
+
closedLabel = "Voting has closed.",
|
|
68
|
+
/** @type {string} Reason voting is unavailable, announced (localize it). */
|
|
69
|
+
disabledReason = "",
|
|
70
|
+
/** @type {((optionId: string) => void) | undefined} */
|
|
71
|
+
onsubmit = undefined,
|
|
72
|
+
/** @type {string} */
|
|
73
|
+
class: className = "",
|
|
74
|
+
/** @type {import('svelte').Snippet | undefined} Bot-protection challenge (Turnstile). */
|
|
75
|
+
captcha = undefined,
|
|
76
|
+
/** @type {import('svelte').Snippet | undefined} Pseudonymised receipt, shown after voting. */
|
|
77
|
+
receipt = undefined,
|
|
78
|
+
...rest
|
|
79
|
+
} = $props();
|
|
80
|
+
|
|
81
|
+
const canVote = $derived(!closed && !submitted && !disabled);
|
|
82
|
+
const total = $derived(
|
|
83
|
+
options.reduce((sum, o) => sum + (o.votes ?? 0), 0),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** @param {number | undefined} votes */
|
|
87
|
+
const pct = (votes) => (total > 0 ? Math.round(((votes ?? 0) / total) * 100) : 0);
|
|
88
|
+
|
|
89
|
+
/** @param {SubmitEvent} e */
|
|
90
|
+
function handleSubmit(e) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (canVote && selected) onsubmit?.(selected);
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<section class="voting-widget {className}" {...rest}>
|
|
97
|
+
<form class="voting-form" onsubmit={handleSubmit} novalidate>
|
|
98
|
+
<fieldset class="voting-fieldset" disabled={!canVote}>
|
|
99
|
+
<legend class="voting-legend">{question}</legend>
|
|
100
|
+
|
|
101
|
+
{#if disabled && disabledReason}
|
|
102
|
+
<p class="voting-reason" role="note">{disabledReason}</p>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<ul class="voting-options">
|
|
106
|
+
{#each options as option (option.id)}
|
|
107
|
+
<li class="voting-option">
|
|
108
|
+
<label class="voting-label">
|
|
109
|
+
<input
|
|
110
|
+
type="radio"
|
|
111
|
+
class="voting-radio"
|
|
112
|
+
{name}
|
|
113
|
+
value={option.id}
|
|
114
|
+
bind:group={selected}
|
|
115
|
+
disabled={!canVote}
|
|
116
|
+
/>
|
|
117
|
+
<span class="voting-option-text">{option.label}</span>
|
|
118
|
+
{#if showResults}
|
|
119
|
+
<span class="voting-pct">{pct(option.votes)}%</span>
|
|
120
|
+
{/if}
|
|
121
|
+
</label>
|
|
122
|
+
{#if showResults}
|
|
123
|
+
<div class="voting-bar" aria-hidden="true">
|
|
124
|
+
<div class="voting-bar-fill" style:width={`${pct(option.votes)}%`}></div>
|
|
125
|
+
</div>
|
|
126
|
+
{/if}
|
|
127
|
+
</li>
|
|
128
|
+
{/each}
|
|
129
|
+
</ul>
|
|
130
|
+
</fieldset>
|
|
131
|
+
|
|
132
|
+
{#if canVote}
|
|
133
|
+
{#if captcha}<div class="voting-captcha">{@render captcha()}</div>{/if}
|
|
134
|
+
<button type="submit" class="voting-submit" disabled={!selected}>
|
|
135
|
+
{submitLabel}
|
|
136
|
+
</button>
|
|
137
|
+
{/if}
|
|
138
|
+
</form>
|
|
139
|
+
|
|
140
|
+
<!-- Status region: confirmation / closed notice land here so AT announces them. -->
|
|
141
|
+
<div class="voting-status" role="status" aria-live="polite">
|
|
142
|
+
{#if submitted}
|
|
143
|
+
<p class="voting-confirm">{submittedLabel}</p>
|
|
144
|
+
{#if receipt}<div class="voting-receipt">{@render receipt()}</div>{/if}
|
|
145
|
+
{:else if closed}
|
|
146
|
+
<p class="voting-closed">{closedLabel}</p>
|
|
147
|
+
{/if}
|
|
148
|
+
</div>
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<style>
|
|
152
|
+
.voting-widget {
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-direction: column;
|
|
155
|
+
gap: var(--space-lg);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.voting-form {
|
|
159
|
+
display: flex;
|
|
160
|
+
flex-direction: column;
|
|
161
|
+
gap: var(--space-lg);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.voting-fieldset {
|
|
165
|
+
margin: 0;
|
|
166
|
+
padding: 0;
|
|
167
|
+
border: 0;
|
|
168
|
+
min-width: 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.voting-legend {
|
|
172
|
+
padding: 0;
|
|
173
|
+
margin-bottom: var(--space-md);
|
|
174
|
+
font-family: var(--type-heading-sm-font);
|
|
175
|
+
font-size: var(--type-heading-sm-size);
|
|
176
|
+
color: var(--color-text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.voting-reason {
|
|
180
|
+
margin: 0 0 var(--space-md);
|
|
181
|
+
font-family: var(--type-body-sm-font);
|
|
182
|
+
font-size: var(--type-body-sm-size);
|
|
183
|
+
color: var(--color-text-muted);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.voting-options {
|
|
187
|
+
list-style: none;
|
|
188
|
+
margin: 0;
|
|
189
|
+
padding: 0;
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-direction: column;
|
|
192
|
+
gap: var(--space-sm);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.voting-label {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: var(--space-sm);
|
|
199
|
+
padding: var(--space-sm) var(--space-md);
|
|
200
|
+
border: 1px solid var(--color-border);
|
|
201
|
+
border-radius: var(--radius-md);
|
|
202
|
+
background: var(--color-surface);
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
}
|
|
205
|
+
/* Selected row reads as chosen without relying on the native dot alone. */
|
|
206
|
+
.voting-label:has(.voting-radio:checked) {
|
|
207
|
+
border-color: var(--color-accent);
|
|
208
|
+
background: var(--color-accent-subtle);
|
|
209
|
+
}
|
|
210
|
+
.voting-label:has(.voting-radio:focus-visible) {
|
|
211
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
212
|
+
outline-offset: var(--focus-ring-offset);
|
|
213
|
+
}
|
|
214
|
+
.voting-fieldset:disabled .voting-label {
|
|
215
|
+
cursor: default;
|
|
216
|
+
opacity: 0.7;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.voting-radio {
|
|
220
|
+
flex-shrink: 0;
|
|
221
|
+
accent-color: var(--color-accent);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.voting-option-text {
|
|
225
|
+
flex: 1;
|
|
226
|
+
font-family: var(--type-body-font);
|
|
227
|
+
font-size: var(--type-body-size);
|
|
228
|
+
color: var(--color-text);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.voting-pct {
|
|
232
|
+
font-family: var(--type-data-font);
|
|
233
|
+
font-size: var(--type-data-size);
|
|
234
|
+
color: var(--color-text-secondary);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.voting-bar {
|
|
238
|
+
height: var(--space-2xs);
|
|
239
|
+
margin-top: var(--space-2xs);
|
|
240
|
+
border-radius: var(--radius-pill);
|
|
241
|
+
background: var(--color-surface-tertiary);
|
|
242
|
+
overflow: hidden;
|
|
243
|
+
}
|
|
244
|
+
.voting-bar-fill {
|
|
245
|
+
height: 100%;
|
|
246
|
+
background: var(--color-accent);
|
|
247
|
+
transition: width var(--duration-normal) var(--easing-default);
|
|
248
|
+
}
|
|
249
|
+
@media (prefers-reduced-motion: reduce) {
|
|
250
|
+
.voting-bar-fill {
|
|
251
|
+
transition: none;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.voting-submit {
|
|
256
|
+
align-self: flex-start;
|
|
257
|
+
padding: var(--space-sm) var(--space-lg);
|
|
258
|
+
border: 0;
|
|
259
|
+
border-radius: var(--radius-md);
|
|
260
|
+
background: var(--color-accent);
|
|
261
|
+
color: var(--color-text-on-accent);
|
|
262
|
+
font-family: var(--type-label-font);
|
|
263
|
+
font-size: var(--type-label-size);
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
}
|
|
266
|
+
.voting-submit:hover:not(:disabled) {
|
|
267
|
+
background: var(--color-accent-hover);
|
|
268
|
+
}
|
|
269
|
+
.voting-submit:focus-visible {
|
|
270
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
271
|
+
outline-offset: var(--focus-ring-offset);
|
|
272
|
+
}
|
|
273
|
+
.voting-submit:disabled {
|
|
274
|
+
opacity: 0.5;
|
|
275
|
+
cursor: not-allowed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.voting-confirm,
|
|
279
|
+
.voting-closed {
|
|
280
|
+
margin: 0;
|
|
281
|
+
font-family: var(--type-label-font);
|
|
282
|
+
font-size: var(--type-label-size);
|
|
283
|
+
color: var(--color-text);
|
|
284
|
+
}
|
|
285
|
+
.voting-confirm {
|
|
286
|
+
color: var(--color-success);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.voting-receipt {
|
|
290
|
+
margin-top: var(--space-sm);
|
|
291
|
+
}
|
|
292
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export default VotingWidget;
|
|
2
|
+
type VotingWidget = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* VotingWidget
|
|
8
|
+
*
|
|
9
|
+
* Participation vote panel — the Valongo V10 participation-grid primitive
|
|
10
|
+
* (participatory budget, consultations, local polls). One question, a radio
|
|
11
|
+
* group of options, a submit path, and a pseudonymised receipt after voting.
|
|
12
|
+
* Presentational: it emits `onsubmit(optionId)`; the portal wires that to the
|
|
13
|
+
* BFF public submit surface (`POST /{app}/public/submit`, voting placement —
|
|
14
|
+
* #70 M4), and renders the bot-protection challenge into the `captcha` slot.
|
|
15
|
+
*
|
|
16
|
+
* States (mutually exclusive vote-ability):
|
|
17
|
+
* - open — radio group + submit (the default)
|
|
18
|
+
* - submitted — confirmation + the `receipt` slot, voting locked
|
|
19
|
+
* - closed — voting period over; results only
|
|
20
|
+
* - disabled — not eligible (e.g. anonymous where login is required);
|
|
21
|
+
* options shown read-only with an announced `disabledReason`
|
|
22
|
+
* Pass `showResults` to render the tally as labelled bars (typically once
|
|
23
|
+
* `closed` or `submitted`).
|
|
24
|
+
*
|
|
25
|
+
* Accessibility:
|
|
26
|
+
* - `<fieldset>` + `<legend>` (the question) groups the options as a native
|
|
27
|
+
* radio group — arrow-key navigation and "n of m" come for free.
|
|
28
|
+
* - Submit is disabled until an option is chosen; the confirmation + receipt
|
|
29
|
+
* land in an `aria-live="polite"` region so a screen reader announces them.
|
|
30
|
+
* - Result bars are `aria-hidden`; each option's percentage is in its visible
|
|
31
|
+
* text, so the meaning isn't colour/width-only.
|
|
32
|
+
* - Radios keep a visible focus ring; reduced-motion disables the bar fill.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <VotingWidget
|
|
36
|
+
* question="Which project should Valongo fund first?"
|
|
37
|
+
* name="pb-2026"
|
|
38
|
+
* options={[{ id: "a", label: "Riverside path" }, { id: "b", label: "School playground" }]}
|
|
39
|
+
* bind:selected
|
|
40
|
+
* onsubmit={(id) => postVote(id)}
|
|
41
|
+
* >
|
|
42
|
+
* {#snippet captcha()}<TurnstileWidget /> {/snippet}
|
|
43
|
+
* </VotingWidget>
|
|
44
|
+
*/
|
|
45
|
+
declare const VotingWidget: import("svelte").Component<{
|
|
46
|
+
question?: string;
|
|
47
|
+
options?: any[];
|
|
48
|
+
selected?: any;
|
|
49
|
+
name?: string;
|
|
50
|
+
closed?: boolean;
|
|
51
|
+
submitted?: boolean;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
showResults?: boolean;
|
|
54
|
+
submitLabel?: string;
|
|
55
|
+
submittedLabel?: string;
|
|
56
|
+
closedLabel?: string;
|
|
57
|
+
disabledReason?: string;
|
|
58
|
+
onsubmit?: any;
|
|
59
|
+
class?: string;
|
|
60
|
+
captcha?: any;
|
|
61
|
+
receipt?: any;
|
|
62
|
+
} & Record<string, any>, {}, "selected">;
|
|
63
|
+
type $$ComponentProps = {
|
|
64
|
+
question?: string;
|
|
65
|
+
options?: any[];
|
|
66
|
+
selected?: any;
|
|
67
|
+
name?: string;
|
|
68
|
+
closed?: boolean;
|
|
69
|
+
submitted?: boolean;
|
|
70
|
+
disabled?: boolean;
|
|
71
|
+
showResults?: boolean;
|
|
72
|
+
submitLabel?: string;
|
|
73
|
+
submittedLabel?: string;
|
|
74
|
+
closedLabel?: string;
|
|
75
|
+
disabledReason?: string;
|
|
76
|
+
onsubmit?: any;
|
|
77
|
+
class?: string;
|
|
78
|
+
captcha?: any;
|
|
79
|
+
receipt?: any;
|
|
80
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component WidgetGrid
|
|
3
|
+
|
|
4
|
+
Responsive dashboard layout for the citizen portal's `landing` /
|
|
5
|
+
information-portal pages, where blocks vary in weight — a hero stat, a list of
|
|
6
|
+
recent consultations, a map, a voting panel. Where CardGrid lays out equal
|
|
7
|
+
cards, WidgetGrid is a column grid whose children may declare a span, so a
|
|
8
|
+
feature widget can sit beside two narrow ones.
|
|
9
|
+
|
|
10
|
+
Collapses to one column on small screens (every widget full-width), goes to
|
|
11
|
+
two at tablet, and to `columns` at desktop. Children opt into width via the
|
|
12
|
+
span helper classes (applied to the direct child, e.g. a `<Card>` or
|
|
13
|
+
`<section>`):
|
|
14
|
+
|
|
15
|
+
- `widget-span-2` — span two columns from tablet up
|
|
16
|
+
- `widget-span-full` — span the full row at every breakpoint
|
|
17
|
+
|
|
18
|
+
Layout only — it sets no landmark and imposes no semantics; the page supplies
|
|
19
|
+
headings and regions.
|
|
20
|
+
|
|
21
|
+
@example
|
|
22
|
+
<WidgetGrid columns="3">
|
|
23
|
+
<section class="widget-span-2"><Hero ... /></section>
|
|
24
|
+
<Card>…</Card>
|
|
25
|
+
<Card class="widget-span-full">…</Card>
|
|
26
|
+
</WidgetGrid>
|
|
27
|
+
-->
|
|
28
|
+
<script>
|
|
29
|
+
let {
|
|
30
|
+
/** @type {'2' | '3' | '4'} Column count at the desktop breakpoint. */
|
|
31
|
+
columns = "3",
|
|
32
|
+
/** @type {string} */
|
|
33
|
+
class: className = "",
|
|
34
|
+
/** @type {import('svelte').Snippet | undefined} */
|
|
35
|
+
children = undefined,
|
|
36
|
+
...rest
|
|
37
|
+
} = $props();
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="widget-grid widget-grid-{columns} {className}" {...rest}>
|
|
41
|
+
{#if children}{@render children()}{/if}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.widget-grid {
|
|
46
|
+
display: grid;
|
|
47
|
+
gap: var(--grid-gutter);
|
|
48
|
+
grid-template-columns: 1fr;
|
|
49
|
+
align-items: start;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Keep widgets from blowing out their track. */
|
|
53
|
+
.widget-grid > :global(*) {
|
|
54
|
+
min-width: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ─── Tablet: two columns, span helpers come alive ─── */
|
|
58
|
+
@media (min-width: 768px) {
|
|
59
|
+
.widget-grid-2,
|
|
60
|
+
.widget-grid-3,
|
|
61
|
+
.widget-grid-4 {
|
|
62
|
+
grid-template-columns: repeat(2, 1fr);
|
|
63
|
+
}
|
|
64
|
+
.widget-grid > :global(.widget-span-2) {
|
|
65
|
+
grid-column: span 2;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ─── Desktop: open up to the configured column count ─── */
|
|
70
|
+
@media (min-width: 1024px) {
|
|
71
|
+
.widget-grid-3 {
|
|
72
|
+
grid-template-columns: repeat(3, 1fr);
|
|
73
|
+
}
|
|
74
|
+
.widget-grid-4 {
|
|
75
|
+
grid-template-columns: repeat(4, 1fr);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Full-width span at every breakpoint. */
|
|
80
|
+
.widget-grid > :global(.widget-span-full) {
|
|
81
|
+
grid-column: 1 / -1;
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export default WidgetGrid;
|
|
2
|
+
type WidgetGrid = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* WidgetGrid
|
|
8
|
+
*
|
|
9
|
+
* Responsive dashboard layout for the citizen portal's `landing` /
|
|
10
|
+
* information-portal pages, where blocks vary in weight — a hero stat, a list of
|
|
11
|
+
* recent consultations, a map, a voting panel. Where CardGrid lays out equal
|
|
12
|
+
* cards, WidgetGrid is a column grid whose children may declare a span, so a
|
|
13
|
+
* feature widget can sit beside two narrow ones.
|
|
14
|
+
*
|
|
15
|
+
* Collapses to one column on small screens (every widget full-width), goes to
|
|
16
|
+
* two at tablet, and to `columns` at desktop. Children opt into width via the
|
|
17
|
+
* span helper classes (applied to the direct child, e.g. a `<Card>` or
|
|
18
|
+
* `<section>`):
|
|
19
|
+
*
|
|
20
|
+
* - `widget-span-2` — span two columns from tablet up
|
|
21
|
+
* - `widget-span-full` — span the full row at every breakpoint
|
|
22
|
+
*
|
|
23
|
+
* Layout only — it sets no landmark and imposes no semantics; the page supplies
|
|
24
|
+
* headings and regions.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <WidgetGrid columns="3">
|
|
28
|
+
* <section class="widget-span-2"><Hero ... /></section>
|
|
29
|
+
* <Card>…</Card>
|
|
30
|
+
* <Card class="widget-span-full">…</Card>
|
|
31
|
+
* </WidgetGrid>
|
|
32
|
+
*/
|
|
33
|
+
declare const WidgetGrid: import("svelte").Component<{
|
|
34
|
+
columns?: string;
|
|
35
|
+
class?: string;
|
|
36
|
+
children?: any;
|
|
37
|
+
} & Record<string, any>, {}, "">;
|
|
38
|
+
type $$ComponentProps = {
|
|
39
|
+
columns?: string;
|
|
40
|
+
class?: string;
|
|
41
|
+
children?: any;
|
|
42
|
+
} & Record<string, any>;
|
package/components/index.js
CHANGED
|
@@ -33,6 +33,19 @@ export { default as List } from "./List.svelte";
|
|
|
33
33
|
export { default as ListItem } from "./ListItem.svelte";
|
|
34
34
|
export { default as PageContainer } from "./PageContainer.svelte";
|
|
35
35
|
|
|
36
|
+
// Public site shell (locked a11y chrome — citizen portal, #7/#71)
|
|
37
|
+
export { default as AppFrame } from "./AppFrame.svelte";
|
|
38
|
+
export { default as SiteHeader } from "./SiteHeader.svelte";
|
|
39
|
+
export { default as SiteFooter } from "./SiteFooter.svelte";
|
|
40
|
+
export { default as SkipLink } from "./SkipLink.svelte";
|
|
41
|
+
export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
|
|
42
|
+
export { default as Link } from "./Link.svelte";
|
|
43
|
+
export { default as Hero } from "./Hero.svelte";
|
|
44
|
+
export { default as ContentBlock } from "./ContentBlock.svelte";
|
|
45
|
+
export { default as StatusTimeline } from "./StatusTimeline.svelte";
|
|
46
|
+
export { default as WidgetGrid } from "./WidgetGrid.svelte";
|
|
47
|
+
export { default as VotingWidget } from "./VotingWidget.svelte";
|
|
48
|
+
|
|
36
49
|
// Containers
|
|
37
50
|
export { default as Card } from "./Card.svelte";
|
|
38
51
|
export { default as Panel } from "./Panel.svelte";
|