@aiaiai-pt/design-system 0.21.0 → 0.23.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/NotificationPrefs.svelte +111 -0
- package/components/NotificationPrefs.svelte.d.ts +55 -0
- package/components/RankingBoard.svelte +159 -0
- package/components/RankingBoard.svelte.d.ts +51 -0
- package/components/ResultsChart.svelte +185 -0
- package/components/ResultsChart.svelte.d.ts +53 -0
- package/components/index.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component NotificationPrefs
|
|
3
|
+
|
|
4
|
+
The citizen account area's notification-preferences panel — a toggle list over
|
|
5
|
+
a citizen's subscriptions. Vertical-agnostic: `items` is already shaped to
|
|
6
|
+
`{ id, label, description?, active }`; the consumer (a portal widget over the
|
|
7
|
+
account subscription lane) maps the backend rows and owns the persistence.
|
|
8
|
+
Toggling a row calls `onToggle(id, next)` — the consumer performs the write
|
|
9
|
+
and re-feeds `items` (or sets `busyId` while it's in flight).
|
|
10
|
+
|
|
11
|
+
Accessibility-first: a bordered semantic list inside a `<section>` region
|
|
12
|
+
named by `label` (the visible page heading is the consumer's job — same split
|
|
13
|
+
as RankingBoard, so one widget can own the page `<h1>`). Each row pairs a real
|
|
14
|
+
text label (and optional description) with a `Toggle` (`role="switch"`,
|
|
15
|
+
`aria-checked`); the switch carries its own accessible name via `aria-label`.
|
|
16
|
+
A row that's saving is `disabled` (no double-submit). Consumes semantic tokens
|
|
17
|
+
so dark / high-contrast schemes (#244) ride through. Soft-empty: no items →
|
|
18
|
+
renders the `emptyText` note (never an empty shell).
|
|
19
|
+
|
|
20
|
+
@example
|
|
21
|
+
<NotificationPrefs
|
|
22
|
+
label="Notifications"
|
|
23
|
+
items={[
|
|
24
|
+
{ id: "all", label: "All updates", description: "Every proposal and phase change", active: true },
|
|
25
|
+
{ id: "sub-2", label: "Cycling lane", active: false },
|
|
26
|
+
]}
|
|
27
|
+
onToggle={(id, next) => save(id, next)}
|
|
28
|
+
busyId={savingId}
|
|
29
|
+
/>
|
|
30
|
+
-->
|
|
31
|
+
<script module>
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{ id: string, label: string, description?: string, active: boolean }} PrefItem
|
|
34
|
+
*/
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
import List from "./List.svelte";
|
|
39
|
+
import ListItem from "./ListItem.svelte";
|
|
40
|
+
import Toggle from "./Toggle.svelte";
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
/** @type {PrefItem[]} The subscription rows, already mapped to labels. */
|
|
44
|
+
items = [],
|
|
45
|
+
/** @type {string} Accessible name for the preferences region (localize it). */
|
|
46
|
+
label = "Notification preferences",
|
|
47
|
+
/** @type {string} Shown when there are no items (localize it). */
|
|
48
|
+
emptyText = "You have no notification preferences yet.",
|
|
49
|
+
/** @type {((id: string, active: boolean) => void) | undefined} Called on toggle. */
|
|
50
|
+
onToggle = undefined,
|
|
51
|
+
/** @type {string | null} The row currently saving — its toggle disables. */
|
|
52
|
+
busyId = null,
|
|
53
|
+
/** @type {string} */
|
|
54
|
+
class: className = "",
|
|
55
|
+
...rest
|
|
56
|
+
} = $props();
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<section class="notification-prefs {className}" aria-label={label} {...rest}>
|
|
60
|
+
{#if items.length > 0}
|
|
61
|
+
<List variant="bordered">
|
|
62
|
+
{#each items as item (item.id)}
|
|
63
|
+
<ListItem>
|
|
64
|
+
{#snippet leading()}
|
|
65
|
+
<span class="notification-prefs-label">{item.label}</span>
|
|
66
|
+
{#if item.description}
|
|
67
|
+
<span class="notification-prefs-desc">{item.description}</span>
|
|
68
|
+
{/if}
|
|
69
|
+
{/snippet}
|
|
70
|
+
{#snippet trailing()}
|
|
71
|
+
<Toggle
|
|
72
|
+
checked={item.active}
|
|
73
|
+
disabled={busyId === item.id}
|
|
74
|
+
aria-label={item.label}
|
|
75
|
+
onchange={(next) => onToggle?.(item.id, next)}
|
|
76
|
+
/>
|
|
77
|
+
{/snippet}
|
|
78
|
+
</ListItem>
|
|
79
|
+
{/each}
|
|
80
|
+
</List>
|
|
81
|
+
{:else}
|
|
82
|
+
<p class="notification-prefs-empty">{emptyText}</p>
|
|
83
|
+
{/if}
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.notification-prefs {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: var(--space-md);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.notification-prefs-label {
|
|
94
|
+
font-family: var(--type-body-font);
|
|
95
|
+
font-size: var(--type-body-size);
|
|
96
|
+
color: var(--color-text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.notification-prefs-desc {
|
|
100
|
+
font-family: var(--type-body-sm-font);
|
|
101
|
+
font-size: var(--type-body-sm-size);
|
|
102
|
+
color: var(--color-text-muted);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.notification-prefs-empty {
|
|
106
|
+
font-family: var(--type-body-font);
|
|
107
|
+
font-size: var(--type-body-size);
|
|
108
|
+
color: var(--color-text-muted);
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export default NotificationPrefs;
|
|
2
|
+
export type PrefItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
active: boolean;
|
|
7
|
+
};
|
|
8
|
+
type NotificationPrefs = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* NotificationPrefs
|
|
14
|
+
*
|
|
15
|
+
* The citizen account area's notification-preferences panel — a toggle list over
|
|
16
|
+
* a citizen's subscriptions. Vertical-agnostic: `items` is already shaped to
|
|
17
|
+
* `{ id, label, description?, active }`; the consumer (a portal widget over the
|
|
18
|
+
* account subscription lane) maps the backend rows and owns the persistence.
|
|
19
|
+
* Toggling a row calls `onToggle(id, next)` — the consumer performs the write
|
|
20
|
+
* and re-feeds `items` (or sets `busyId` while it's in flight).
|
|
21
|
+
*
|
|
22
|
+
* Accessibility-first: a bordered semantic list; each row pairs a real text
|
|
23
|
+
* label (and optional description) with a `Toggle` (`role="switch"`,
|
|
24
|
+
* `aria-checked`). The switch carries an accessible name via `aria-label` so the
|
|
25
|
+
* control reads on its own. A row that's saving is `disabled` (no double-submit).
|
|
26
|
+
* Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
|
|
27
|
+
* Soft-empty: no items → renders the `emptyText` note (never an empty shell).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <NotificationPrefs
|
|
31
|
+
* heading="Notifications"
|
|
32
|
+
* items={[
|
|
33
|
+
* { id: "all", label: "All updates", description: "Every proposal and phase change", active: true },
|
|
34
|
+
* { id: "sub-2", label: "Cycling lane", active: false },
|
|
35
|
+
* ]}
|
|
36
|
+
* onToggle={(id, next) => save(id, next)}
|
|
37
|
+
* busyId={savingId}
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
declare const NotificationPrefs: import("svelte").Component<{
|
|
41
|
+
items?: any[];
|
|
42
|
+
heading?: string;
|
|
43
|
+
emptyText?: string;
|
|
44
|
+
onToggle?: any;
|
|
45
|
+
busyId?: any;
|
|
46
|
+
class?: string;
|
|
47
|
+
} & Record<string, any>, {}, "">;
|
|
48
|
+
type $$ComponentProps = {
|
|
49
|
+
items?: any[];
|
|
50
|
+
heading?: string;
|
|
51
|
+
emptyText?: string;
|
|
52
|
+
onToggle?: any;
|
|
53
|
+
busyId?: any;
|
|
54
|
+
class?: string;
|
|
55
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component RankingBoard
|
|
3
|
+
|
|
4
|
+
An ordered leaderboard of entities ranked by a single aggregate value — the
|
|
5
|
+
citizen portal's "results" surface (winning participatory-budget proposals by
|
|
6
|
+
votes, most-reported categories, top-consuming meters). Vertical-agnostic:
|
|
7
|
+
`items` is already shaped to `{ label, value }`; the consumer (a portal widget
|
|
8
|
+
over a public aggregate VIEW) does the field mapping and sorting.
|
|
9
|
+
|
|
10
|
+
Accessibility-first: renders a semantic `<ol>` so rank + position are conveyed
|
|
11
|
+
structurally (not just visually), with a visible rank badge and a proportional
|
|
12
|
+
bar (decorative, `aria-hidden`) for at-a-glance weight. The value is real text,
|
|
13
|
+
never encoded only in the bar width — so the ranking reads identically with CSS
|
|
14
|
+
off or to a screen reader. Consumes semantic tokens so dark / high-contrast
|
|
15
|
+
schemes (#244) ride through. Soft-empty: no items → renders nothing.
|
|
16
|
+
|
|
17
|
+
@example
|
|
18
|
+
<RankingBoard
|
|
19
|
+
label="Winning proposals by votes"
|
|
20
|
+
items={[
|
|
21
|
+
{ label: "Ciclovia da Marginal", value: 1284 },
|
|
22
|
+
{ label: "Parque infantil de Gaia", value: 967 },
|
|
23
|
+
{ label: "Iluminação LED do centro", value: 612 },
|
|
24
|
+
]}
|
|
25
|
+
valueSuffix=" votos"
|
|
26
|
+
locale="pt"
|
|
27
|
+
/>
|
|
28
|
+
-->
|
|
29
|
+
<script module>
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ label: string, value: number }} RankingItem
|
|
32
|
+
*/
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
let {
|
|
37
|
+
/** @type {RankingItem[]} Pre-sorted (desc) items; index 0 is rank 1. */
|
|
38
|
+
items = [],
|
|
39
|
+
/** @type {string} Accessible name for the ranked list (localize it). */
|
|
40
|
+
label = "Ranking",
|
|
41
|
+
/** @type {string} Appended after each value (e.g. " votos"); localize it. */
|
|
42
|
+
valueSuffix = "",
|
|
43
|
+
/** @type {string} BCP-47 locale for number formatting. */
|
|
44
|
+
locale = "en",
|
|
45
|
+
/** @type {string} */
|
|
46
|
+
class: className = "",
|
|
47
|
+
...rest
|
|
48
|
+
} = $props();
|
|
49
|
+
|
|
50
|
+
const max = $derived(
|
|
51
|
+
items.reduce((m, it) => (it.value > m ? it.value : m), 0),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** @param {number} value @returns {string} */
|
|
55
|
+
function fmt(value) {
|
|
56
|
+
return new Intl.NumberFormat(locale).format(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Bar width as an integer percent of the max (0 when max is 0).
|
|
60
|
+
* @param {number} value @returns {number} */
|
|
61
|
+
function pct(value) {
|
|
62
|
+
return max > 0 ? Math.round((value / max) * 100) : 0;
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
{#if items.length > 0}
|
|
67
|
+
<ol class="ranking-board {className}" aria-label={label} {...rest}>
|
|
68
|
+
{#each items as item, i (item.label + i)}
|
|
69
|
+
<li class="ranking-row">
|
|
70
|
+
<span class="ranking-rank" aria-hidden="true">{i + 1}</span>
|
|
71
|
+
<span class="ranking-body">
|
|
72
|
+
<span class="ranking-label">{item.label}</span>
|
|
73
|
+
<span class="ranking-bar" aria-hidden="true">
|
|
74
|
+
<span class="ranking-bar-fill" style="inline-size: {pct(item.value)}%"
|
|
75
|
+
></span>
|
|
76
|
+
</span>
|
|
77
|
+
</span>
|
|
78
|
+
<span class="ranking-value">{fmt(item.value)}{valueSuffix}</span>
|
|
79
|
+
</li>
|
|
80
|
+
{/each}
|
|
81
|
+
</ol>
|
|
82
|
+
{/if}
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.ranking-board {
|
|
86
|
+
list-style: none;
|
|
87
|
+
margin: 0;
|
|
88
|
+
padding: 0;
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
gap: var(--space-xs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.ranking-row {
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-columns: auto 1fr auto;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: var(--space-sm);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.ranking-rank {
|
|
102
|
+
inline-size: 1.75rem;
|
|
103
|
+
block-size: 1.75rem;
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: center;
|
|
107
|
+
border-radius: var(--radius-full, 999px);
|
|
108
|
+
background: transparent;
|
|
109
|
+
border: 1px solid var(--color-border);
|
|
110
|
+
color: var(--color-text-secondary);
|
|
111
|
+
font-size: var(--type-body-sm-size);
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
font-variant-numeric: tabular-nums;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The top three carry the accent fill so the podium reads at a glance —
|
|
117
|
+
redundant with the ordinal position (never the only signal). */
|
|
118
|
+
.ranking-row:nth-child(-n + 3) .ranking-rank {
|
|
119
|
+
background: var(--color-accent);
|
|
120
|
+
border-color: var(--color-accent);
|
|
121
|
+
color: var(--color-text-on-accent);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ranking-body {
|
|
125
|
+
min-inline-size: 0;
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: var(--space-3xs, 2px);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.ranking-label {
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
text-overflow: ellipsis;
|
|
134
|
+
white-space: nowrap;
|
|
135
|
+
color: var(--color-text-primary);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.ranking-bar {
|
|
139
|
+
block-size: var(--space-2xs, 6px);
|
|
140
|
+
inline-size: 100%;
|
|
141
|
+
border-radius: var(--radius-full, 999px);
|
|
142
|
+
background: var(--color-border);
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.ranking-bar-fill {
|
|
147
|
+
display: block;
|
|
148
|
+
block-size: 100%;
|
|
149
|
+
background: var(--color-accent);
|
|
150
|
+
border-radius: inherit;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ranking-value {
|
|
154
|
+
font-variant-numeric: tabular-nums;
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
color: var(--color-text-primary);
|
|
157
|
+
white-space: nowrap;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export default RankingBoard;
|
|
2
|
+
export type RankingItem = {
|
|
3
|
+
label: string;
|
|
4
|
+
value: number;
|
|
5
|
+
};
|
|
6
|
+
type RankingBoard = {
|
|
7
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
8
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* RankingBoard
|
|
12
|
+
*
|
|
13
|
+
* An ordered leaderboard of entities ranked by a single aggregate value — the
|
|
14
|
+
* citizen portal's "results" surface (winning participatory-budget proposals by
|
|
15
|
+
* votes, most-reported categories, top-consuming meters). Vertical-agnostic:
|
|
16
|
+
* `items` is already shaped to `{ label, value }`; the consumer (a portal widget
|
|
17
|
+
* over a public aggregate VIEW) does the field mapping and sorting.
|
|
18
|
+
*
|
|
19
|
+
* Accessibility-first: renders a semantic `<ol>` so rank + position are conveyed
|
|
20
|
+
* structurally (not just visually), with a visible rank badge and a proportional
|
|
21
|
+
* bar (decorative, `aria-hidden`) for at-a-glance weight. The value is real text,
|
|
22
|
+
* never encoded only in the bar width — so the ranking reads identically with CSS
|
|
23
|
+
* off or to a screen reader. Consumes semantic tokens so dark / high-contrast
|
|
24
|
+
* schemes (#244) ride through. Soft-empty: no items → renders nothing.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <RankingBoard
|
|
28
|
+
* label="Winning proposals by votes"
|
|
29
|
+
* items={[
|
|
30
|
+
* { label: "Ciclovia da Marginal", value: 1284 },
|
|
31
|
+
* { label: "Parque infantil de Gaia", value: 967 },
|
|
32
|
+
* { label: "Iluminação LED do centro", value: 612 },
|
|
33
|
+
* ]}
|
|
34
|
+
* valueSuffix=" votos"
|
|
35
|
+
* locale="pt"
|
|
36
|
+
* />
|
|
37
|
+
*/
|
|
38
|
+
declare const RankingBoard: import("svelte").Component<{
|
|
39
|
+
items?: any[];
|
|
40
|
+
label?: string;
|
|
41
|
+
valueSuffix?: string;
|
|
42
|
+
locale?: string;
|
|
43
|
+
class?: string;
|
|
44
|
+
} & Record<string, any>, {}, "">;
|
|
45
|
+
type $$ComponentProps = {
|
|
46
|
+
items?: any[];
|
|
47
|
+
label?: string;
|
|
48
|
+
valueSuffix?: string;
|
|
49
|
+
locale?: string;
|
|
50
|
+
class?: string;
|
|
51
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ResultsChart
|
|
3
|
+
|
|
4
|
+
A horizontal bar chart for a single categorical aggregate (votes per proposal,
|
|
5
|
+
reports per category, consumption per meter) that SHIPS ITS DATA TABLE. The
|
|
6
|
+
visual bars are decorative (`aria-hidden`); the accompanying `<table>` is the
|
|
7
|
+
accessible source of truth, always rendered — so the data is reachable with CSS
|
|
8
|
+
off, at any zoom, and to assistive tech. This is a hard requirement (WCAG /
|
|
9
|
+
ARTE: a chart must not be the only encoding of its data), not a toggle.
|
|
10
|
+
|
|
11
|
+
Vertical-agnostic: `items` is already shaped to `{ label, value }`; the consumer
|
|
12
|
+
(a portal widget over a public aggregate VIEW) maps the view columns and orders
|
|
13
|
+
the rows. Dependency-free (pure CSS bars) so it stays a11y- and SSR-clean.
|
|
14
|
+
Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
|
|
15
|
+
Soft-empty: no items → renders nothing.
|
|
16
|
+
|
|
17
|
+
@example
|
|
18
|
+
<ResultsChart
|
|
19
|
+
caption="Votes per proposal"
|
|
20
|
+
labelHeader="Proposal"
|
|
21
|
+
valueHeader="Votes"
|
|
22
|
+
items={[
|
|
23
|
+
{ label: "Ciclovia da Marginal", value: 1284 },
|
|
24
|
+
{ label: "Parque infantil de Gaia", value: 967 },
|
|
25
|
+
]}
|
|
26
|
+
locale="pt"
|
|
27
|
+
/>
|
|
28
|
+
-->
|
|
29
|
+
<script module>
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ label: string, value: number }} ChartItem
|
|
32
|
+
*/
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
let {
|
|
37
|
+
/** @type {ChartItem[]} The categorical rows to plot. */
|
|
38
|
+
items = [],
|
|
39
|
+
/** @type {string} Accessible caption / heading for the chart + table. */
|
|
40
|
+
caption = "Results",
|
|
41
|
+
/** @type {string} Column header for the category (localize it). */
|
|
42
|
+
labelHeader = "Item",
|
|
43
|
+
/** @type {string} Column header for the value (localize it). */
|
|
44
|
+
valueHeader = "Value",
|
|
45
|
+
/** @type {string} BCP-47 locale for number formatting. */
|
|
46
|
+
locale = "en",
|
|
47
|
+
/** @type {string} */
|
|
48
|
+
class: className = "",
|
|
49
|
+
...rest
|
|
50
|
+
} = $props();
|
|
51
|
+
|
|
52
|
+
const max = $derived(
|
|
53
|
+
items.reduce((m, it) => (it.value > m ? it.value : m), 0),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** @param {number} value @returns {string} */
|
|
57
|
+
function fmt(value) {
|
|
58
|
+
return new Intl.NumberFormat(locale).format(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {number} value @returns {number} */
|
|
62
|
+
function pct(value) {
|
|
63
|
+
return max > 0 ? Math.round((value / max) * 100) : 0;
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
{#if items.length > 0}
|
|
68
|
+
<figure class="results-chart {className}" aria-label={caption} {...rest}>
|
|
69
|
+
<!-- Decorative visual: the data lives in the table below. -->
|
|
70
|
+
<div class="results-chart-bars" aria-hidden="true">
|
|
71
|
+
{#each items as item, i (item.label + i)}
|
|
72
|
+
<div class="results-bar-row">
|
|
73
|
+
<span class="results-bar-label">{item.label}</span>
|
|
74
|
+
<span class="results-bar-track">
|
|
75
|
+
<span
|
|
76
|
+
class="results-bar-fill"
|
|
77
|
+
style="inline-size: {pct(item.value)}%"
|
|
78
|
+
></span>
|
|
79
|
+
</span>
|
|
80
|
+
<span class="results-bar-value">{fmt(item.value)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
{/each}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Accessible source of truth — always rendered (ARTE #3/#6). Named via
|
|
86
|
+
aria-label, not a <caption>: position:absolute/clip is unreliable on a
|
|
87
|
+
display:table-caption element (it leaks visibly in some engines). -->
|
|
88
|
+
<table class="results-chart-table" aria-label={caption}>
|
|
89
|
+
<thead>
|
|
90
|
+
<tr>
|
|
91
|
+
<th scope="col">{labelHeader}</th>
|
|
92
|
+
<th scope="col">{valueHeader}</th>
|
|
93
|
+
</tr>
|
|
94
|
+
</thead>
|
|
95
|
+
<tbody>
|
|
96
|
+
{#each items as item, i (item.label + i)}
|
|
97
|
+
<tr>
|
|
98
|
+
<th scope="row">{item.label}</th>
|
|
99
|
+
<td>{fmt(item.value)}</td>
|
|
100
|
+
</tr>
|
|
101
|
+
{/each}
|
|
102
|
+
</tbody>
|
|
103
|
+
</table>
|
|
104
|
+
</figure>
|
|
105
|
+
{/if}
|
|
106
|
+
|
|
107
|
+
<style>
|
|
108
|
+
.results-chart {
|
|
109
|
+
margin: 0;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: var(--space-md);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.results-chart-bars {
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
gap: var(--space-sm);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.results-bar-row {
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-columns: minmax(6rem, 14rem) 1fr auto;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: var(--space-sm);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.results-bar-label {
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-overflow: ellipsis;
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
color: var(--color-text-primary);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.results-bar-track {
|
|
136
|
+
block-size: var(--space-sm, 12px);
|
|
137
|
+
inline-size: 100%;
|
|
138
|
+
background: var(--color-border);
|
|
139
|
+
border-radius: var(--radius-full, 999px);
|
|
140
|
+
overflow: hidden;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.results-bar-fill {
|
|
144
|
+
display: block;
|
|
145
|
+
block-size: 100%;
|
|
146
|
+
background: var(--color-accent);
|
|
147
|
+
border-radius: inherit;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.results-bar-value {
|
|
151
|
+
font-variant-numeric: tabular-nums;
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
color: var(--color-text-primary);
|
|
154
|
+
white-space: nowrap;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.results-chart-table {
|
|
158
|
+
inline-size: 100%;
|
|
159
|
+
border-collapse: collapse;
|
|
160
|
+
font-size: var(--type-body-sm-size);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.results-chart-table th,
|
|
164
|
+
.results-chart-table td {
|
|
165
|
+
text-align: start;
|
|
166
|
+
padding: var(--space-2xs) var(--space-xs);
|
|
167
|
+
border-block-end: 1px solid var(--color-border);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.results-chart-table thead th {
|
|
171
|
+
color: var(--color-text-secondary);
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.results-chart-table tbody th {
|
|
176
|
+
font-weight: 400;
|
|
177
|
+
color: var(--color-text-primary);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.results-chart-table td {
|
|
181
|
+
text-align: end;
|
|
182
|
+
font-variant-numeric: tabular-nums;
|
|
183
|
+
color: var(--color-text-primary);
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export default ResultsChart;
|
|
2
|
+
export type ChartItem = {
|
|
3
|
+
label: string;
|
|
4
|
+
value: number;
|
|
5
|
+
};
|
|
6
|
+
type ResultsChart = {
|
|
7
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
8
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* ResultsChart
|
|
12
|
+
*
|
|
13
|
+
* A horizontal bar chart for a single categorical aggregate (votes per proposal,
|
|
14
|
+
* reports per category, consumption per meter) that SHIPS ITS DATA TABLE. The
|
|
15
|
+
* visual bars are decorative (`aria-hidden`); the accompanying `<table>` is the
|
|
16
|
+
* accessible source of truth, always rendered — so the data is reachable with CSS
|
|
17
|
+
* off, at any zoom, and to assistive tech. This is a hard requirement (WCAG /
|
|
18
|
+
* ARTE: a chart must not be the only encoding of its data), not a toggle.
|
|
19
|
+
*
|
|
20
|
+
* Vertical-agnostic: `items` is already shaped to `{ label, value }`; the consumer
|
|
21
|
+
* (a portal widget over a public aggregate VIEW) maps the view columns and orders
|
|
22
|
+
* the rows. Dependency-free (pure CSS bars) so it stays a11y- and SSR-clean.
|
|
23
|
+
* Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
|
|
24
|
+
* Soft-empty: no items → renders nothing.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <ResultsChart
|
|
28
|
+
* caption="Votes per proposal"
|
|
29
|
+
* labelHeader="Proposal"
|
|
30
|
+
* valueHeader="Votes"
|
|
31
|
+
* items={[
|
|
32
|
+
* { label: "Ciclovia da Marginal", value: 1284 },
|
|
33
|
+
* { label: "Parque infantil de Gaia", value: 967 },
|
|
34
|
+
* ]}
|
|
35
|
+
* locale="pt"
|
|
36
|
+
* />
|
|
37
|
+
*/
|
|
38
|
+
declare const ResultsChart: import("svelte").Component<{
|
|
39
|
+
items?: any[];
|
|
40
|
+
caption?: string;
|
|
41
|
+
labelHeader?: string;
|
|
42
|
+
valueHeader?: string;
|
|
43
|
+
locale?: string;
|
|
44
|
+
class?: string;
|
|
45
|
+
} & Record<string, any>, {}, "">;
|
|
46
|
+
type $$ComponentProps = {
|
|
47
|
+
items?: any[];
|
|
48
|
+
caption?: string;
|
|
49
|
+
labelHeader?: string;
|
|
50
|
+
valueHeader?: string;
|
|
51
|
+
locale?: string;
|
|
52
|
+
class?: string;
|
|
53
|
+
} & Record<string, any>;
|
package/components/index.js
CHANGED
|
@@ -84,6 +84,16 @@ export { default as RecordList } from "./RecordList.svelte";
|
|
|
84
84
|
// Process awareness (#105 Phase 3)
|
|
85
85
|
export { default as PhaseTimeline } from "./PhaseTimeline.svelte";
|
|
86
86
|
|
|
87
|
+
// Results / aggregates (#105 Phase 4) — citizen-facing outcomes over a public
|
|
88
|
+
// aggregate VIEW. ResultsChart ships its data table (a chart is never the only
|
|
89
|
+
// encoding of its data); RankingBoard is a semantic ordered leaderboard.
|
|
90
|
+
export { default as RankingBoard } from "./RankingBoard.svelte";
|
|
91
|
+
export { default as ResultsChart } from "./ResultsChart.svelte";
|
|
92
|
+
|
|
93
|
+
// Account / preferences (#105 Phase 5) — the citizen account area's
|
|
94
|
+
// notification-preferences panel: a toggle list over a citizen's subscriptions.
|
|
95
|
+
export { default as NotificationPrefs } from "./NotificationPrefs.svelte";
|
|
96
|
+
|
|
87
97
|
// Feedback
|
|
88
98
|
export { default as Alert } from "./Alert.svelte";
|
|
89
99
|
export { default as Toast } from "./Toast.svelte";
|