@aiaiai-pt/design-system 0.19.0 → 0.21.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/ActionPanel.svelte +111 -0
- package/components/ActionPanel.svelte.d.ts +44 -0
- package/components/PhaseTimeline.svelte +220 -0
- package/components/PhaseTimeline.svelte.d.ts +52 -0
- package/components/RecordList.svelte +131 -0
- package/components/RecordList.svelte.d.ts +50 -0
- package/components/index.d.ts +2 -0
- package/components/index.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ActionPanel
|
|
3
|
+
|
|
4
|
+
A config-driven vertical stack of detail-page call-to-action buttons —
|
|
5
|
+
vote / support / comment / acknowledge / reserve … Vertical-agnostic: every
|
|
6
|
+
CTA is data ({ label, href, icon?, variant?, disabled?, reason? }).
|
|
7
|
+
|
|
8
|
+
Presentational only — the consumer decides each CTA's enabled/disabled state
|
|
9
|
+
and the reason it's closed (grant or row-state). An ENABLED CTA renders as a
|
|
10
|
+
link (it navigates to the action form); a CLOSED CTA renders as a disabled
|
|
11
|
+
control with its reason as VISIBLE, programmatically-associated helper text
|
|
12
|
+
(`aria-describedby`) — never a dead button. Decorative icons are aria-hidden.
|
|
13
|
+
|
|
14
|
+
Consumes semantic tokens (`--space-*`, `--color-*`) so dark / high-contrast
|
|
15
|
+
schemes (#244) ride through without per-component token entries.
|
|
16
|
+
|
|
17
|
+
@example
|
|
18
|
+
<ActionPanel actions={[
|
|
19
|
+
{ key: 'vote', label: 'Vote for this proposal', href: '/submit/vote', icon: ThumbsUpIcon },
|
|
20
|
+
{ key: 'support', label: 'Support', disabled: true, reason: 'Voting has closed.' },
|
|
21
|
+
]} />
|
|
22
|
+
-->
|
|
23
|
+
<script module>
|
|
24
|
+
let _actionPanelUid = 0;
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* key?: string,
|
|
28
|
+
* label: string,
|
|
29
|
+
* href?: string,
|
|
30
|
+
* icon?: import('svelte').Component,
|
|
31
|
+
* variant?: 'primary' | 'secondary' | 'ghost' | 'destructive',
|
|
32
|
+
* disabled?: boolean,
|
|
33
|
+
* reason?: string,
|
|
34
|
+
* }} ActionPanelItem
|
|
35
|
+
*/
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script>
|
|
39
|
+
import Button from './Button.svelte';
|
|
40
|
+
|
|
41
|
+
let {
|
|
42
|
+
/** @type {ActionPanelItem[]} The CTAs to render, in order. */
|
|
43
|
+
actions = [],
|
|
44
|
+
/** @type {string} */
|
|
45
|
+
class: className = '',
|
|
46
|
+
...rest
|
|
47
|
+
} = $props();
|
|
48
|
+
|
|
49
|
+
const uid = `action-panel-${_actionPanelUid++}`;
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
{#if actions.length > 0}
|
|
53
|
+
<div class="action-panel {className}" {...rest}>
|
|
54
|
+
{#each actions as action, i (action.key ?? i)}
|
|
55
|
+
{@const Icon = action.icon}
|
|
56
|
+
{@const isClosed = action.disabled === true || !action.href}
|
|
57
|
+
{@const reasonId =
|
|
58
|
+
action.disabled === true && action.reason ? `${uid}-reason-${i}` : undefined}
|
|
59
|
+
<div class="action-panel-item">
|
|
60
|
+
<Button
|
|
61
|
+
variant={action.variant ?? 'primary'}
|
|
62
|
+
href={isClosed ? undefined : action.href}
|
|
63
|
+
disabled={isClosed}
|
|
64
|
+
class="action-panel-btn"
|
|
65
|
+
aria-describedby={reasonId}
|
|
66
|
+
>
|
|
67
|
+
{#if Icon}<span class="action-panel-icon" aria-hidden="true"><Icon /></span>{/if}
|
|
68
|
+
{action.label}
|
|
69
|
+
</Button>
|
|
70
|
+
{#if action.disabled === true && action.reason}
|
|
71
|
+
<p class="action-panel-reason" id={reasonId}>{action.reason}</p>
|
|
72
|
+
{/if}
|
|
73
|
+
</div>
|
|
74
|
+
{/each}
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.action-panel {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: var(--space-sm);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.action-panel-item {
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
gap: var(--space-2xs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Ficha CTAs read as a stacked column — each button spans the panel. */
|
|
92
|
+
.action-panel :global(.action-panel-btn) {
|
|
93
|
+
width: 100%;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.action-panel-icon {
|
|
97
|
+
display: inline-flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.action-panel-icon :global(svg) {
|
|
102
|
+
width: var(--icon-size-sm);
|
|
103
|
+
height: var(--icon-size-sm);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.action-panel-reason {
|
|
107
|
+
margin: 0;
|
|
108
|
+
font-size: var(--type-body-sm-size);
|
|
109
|
+
color: var(--color-text-secondary);
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default ActionPanel;
|
|
2
|
+
export type ActionPanelItem = {
|
|
3
|
+
key?: string;
|
|
4
|
+
label: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
icon?: import("svelte").Component;
|
|
7
|
+
variant?: "primary" | "secondary" | "ghost" | "destructive";
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
};
|
|
11
|
+
type ActionPanel = {
|
|
12
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
13
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* ActionPanel
|
|
17
|
+
*
|
|
18
|
+
* A config-driven vertical stack of detail-page call-to-action buttons —
|
|
19
|
+
* vote / support / comment / acknowledge / reserve … Vertical-agnostic: every
|
|
20
|
+
* CTA is data ({ label, href, icon?, variant?, disabled?, reason? }).
|
|
21
|
+
*
|
|
22
|
+
* Presentational only — the consumer decides each CTA's enabled/disabled state
|
|
23
|
+
* and the reason it's closed (grant or row-state). An ENABLED CTA renders as a
|
|
24
|
+
* link (it navigates to the action form); a CLOSED CTA renders as a disabled
|
|
25
|
+
* control with its reason as VISIBLE, programmatically-associated helper text
|
|
26
|
+
* (`aria-describedby`) — never a dead button. Decorative icons are aria-hidden.
|
|
27
|
+
*
|
|
28
|
+
* Consumes semantic tokens (`--space-*`, `--color-*`) so dark / high-contrast
|
|
29
|
+
* schemes (#244) ride through without per-component token entries.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <ActionPanel actions={[
|
|
33
|
+
* { key: 'vote', label: 'Vote for this proposal', href: '/submit/vote', icon: ThumbsUpIcon },
|
|
34
|
+
* { key: 'support', label: 'Support', disabled: true, reason: 'Voting has closed.' },
|
|
35
|
+
* ]} />
|
|
36
|
+
*/
|
|
37
|
+
declare const ActionPanel: import("svelte").Component<{
|
|
38
|
+
actions?: any[];
|
|
39
|
+
class?: string;
|
|
40
|
+
} & Record<string, any>, {}, "">;
|
|
41
|
+
type $$ComponentProps = {
|
|
42
|
+
actions?: any[];
|
|
43
|
+
class?: string;
|
|
44
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component PhaseTimeline
|
|
3
|
+
|
|
4
|
+
Scheduled-phase timeline for the citizen portal — the spine of a phase-driven
|
|
5
|
+
process (participatory budgeting: submission → analysis → voting → execution;
|
|
6
|
+
but equally an SLA cycle, a maintenance plan, or an energy-tariff window).
|
|
7
|
+
|
|
8
|
+
Distinct from StatusTimeline: StatusTimeline tracks a single item's PROGRESS
|
|
9
|
+
(submitted → resolved). PhaseTimeline shows a SCHEDULE — each phase carries a
|
|
10
|
+
date window and a derived state (closed / open / upcoming), and one phase is
|
|
11
|
+
marked the authoritative current phase. The state is computed by the caller
|
|
12
|
+
from the window dates against the request clock so the widget itself is
|
|
13
|
+
clock-free (SSR-stable, reduced-motion-safe — no animation).
|
|
14
|
+
|
|
15
|
+
Renders an `<ol>` so order and position are structural; the current phase
|
|
16
|
+
carries `aria-current="step"`, and each phase's state is announced via a
|
|
17
|
+
visually-hidden label (pass `stateLabels` to localise — the portal feeds
|
|
18
|
+
Wuchale strings here).
|
|
19
|
+
|
|
20
|
+
@example
|
|
21
|
+
<PhaseTimeline
|
|
22
|
+
label="Participatory budgeting calendar"
|
|
23
|
+
phases={[
|
|
24
|
+
{ label: "Proposals", status: "closed", window: "1–31 Mar" },
|
|
25
|
+
{ label: "Analysis", status: "closed", window: "1–15 Apr" },
|
|
26
|
+
{ label: "Voting", status: "open", current: true, window: "16–30 Apr", description: "Vote now" },
|
|
27
|
+
{ label: "Execution", status: "upcoming", window: "from 1 Jun" },
|
|
28
|
+
]}
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
@example Localised state labels (portal i18n)
|
|
32
|
+
<PhaseTimeline phases={phases} stateLabels={{ closed: "Encerrada", open: "A decorrer", upcoming: "Brevemente" }} openBadge="A decorrer" />
|
|
33
|
+
-->
|
|
34
|
+
<script>
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {'closed' | 'open' | 'upcoming'} PhaseStatus
|
|
37
|
+
* @typedef {{ label: string, status?: PhaseStatus, window?: string, current?: boolean, description?: string }} Phase
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
/** @type {Phase[]} Ordered phases, earliest first. */
|
|
42
|
+
phases = [],
|
|
43
|
+
/** @type {string} Accessible name for the timeline list (localize it). */
|
|
44
|
+
label = "Phases",
|
|
45
|
+
/**
|
|
46
|
+
* @type {Record<PhaseStatus, string>}
|
|
47
|
+
* Visually-hidden state text announced per phase (localize it).
|
|
48
|
+
*/
|
|
49
|
+
stateLabels = {
|
|
50
|
+
closed: "Closed",
|
|
51
|
+
open: "Open now",
|
|
52
|
+
upcoming: "Upcoming",
|
|
53
|
+
},
|
|
54
|
+
/** @type {string} Visible badge text on the open phase (localize it). */
|
|
55
|
+
openBadge = "Open now",
|
|
56
|
+
/** @type {string} */
|
|
57
|
+
class: className = "",
|
|
58
|
+
...rest
|
|
59
|
+
} = $props();
|
|
60
|
+
|
|
61
|
+
/** @param {Phase} phase @returns {PhaseStatus} */
|
|
62
|
+
const statusOf = (phase) => phase.status ?? "upcoming";
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{#if phases.length > 0}
|
|
66
|
+
<ol class="phase-timeline {className}" aria-label={label} {...rest}>
|
|
67
|
+
{#each phases as phase, i (i)}
|
|
68
|
+
{@const status = statusOf(phase)}
|
|
69
|
+
<li
|
|
70
|
+
class="phase-timeline-item phase-timeline-item-{status}"
|
|
71
|
+
class:phase-timeline-item-current={phase.current}
|
|
72
|
+
aria-current={phase.current ? "step" : undefined}
|
|
73
|
+
>
|
|
74
|
+
<span class="phase-timeline-marker" aria-hidden="true">
|
|
75
|
+
<span class="phase-timeline-node"></span>
|
|
76
|
+
</span>
|
|
77
|
+
<div class="phase-timeline-body">
|
|
78
|
+
<span class="phase-timeline-label">
|
|
79
|
+
<span class="phase-timeline-state">{stateLabels[status]}: </span>
|
|
80
|
+
{phase.label}
|
|
81
|
+
{#if status === "open"}
|
|
82
|
+
<span class="phase-timeline-badge">{openBadge}</span>
|
|
83
|
+
{/if}
|
|
84
|
+
</span>
|
|
85
|
+
{#if phase.window}
|
|
86
|
+
<span class="phase-timeline-window">{phase.window}</span>
|
|
87
|
+
{/if}
|
|
88
|
+
{#if phase.description}
|
|
89
|
+
<p class="phase-timeline-desc">{phase.description}</p>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
92
|
+
</li>
|
|
93
|
+
{/each}
|
|
94
|
+
</ol>
|
|
95
|
+
{/if}
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
.phase-timeline {
|
|
99
|
+
list-style: none;
|
|
100
|
+
margin: 0;
|
|
101
|
+
padding: 0;
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.phase-timeline-item {
|
|
107
|
+
position: relative;
|
|
108
|
+
display: grid;
|
|
109
|
+
grid-template-columns: auto 1fr;
|
|
110
|
+
gap: var(--space-md);
|
|
111
|
+
padding-bottom: var(--space-lg);
|
|
112
|
+
}
|
|
113
|
+
.phase-timeline-item:last-child {
|
|
114
|
+
padding-bottom: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Connector line down the marker column, behind the nodes; stops at the last
|
|
118
|
+
node so it never dangles past the final phase. */
|
|
119
|
+
.phase-timeline-marker {
|
|
120
|
+
position: relative;
|
|
121
|
+
display: flex;
|
|
122
|
+
justify-content: center;
|
|
123
|
+
width: var(--space-md);
|
|
124
|
+
}
|
|
125
|
+
.phase-timeline-item:not(:last-child) .phase-timeline-marker::before {
|
|
126
|
+
content: "";
|
|
127
|
+
position: absolute;
|
|
128
|
+
top: var(--space-md);
|
|
129
|
+
bottom: calc(-1 * var(--space-lg));
|
|
130
|
+
inset-inline-start: 50%;
|
|
131
|
+
transform: translateX(-50%);
|
|
132
|
+
width: var(--border-width-thick);
|
|
133
|
+
background: var(--color-border);
|
|
134
|
+
}
|
|
135
|
+
/* A closed (past) phase's connector is filled — the schedule has advanced. */
|
|
136
|
+
.phase-timeline-item-closed:not(:last-child) .phase-timeline-marker::before {
|
|
137
|
+
background: var(--color-accent);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.phase-timeline-node {
|
|
141
|
+
position: relative;
|
|
142
|
+
z-index: 1;
|
|
143
|
+
width: var(--space-md);
|
|
144
|
+
height: var(--space-md);
|
|
145
|
+
border-radius: var(--radius-circle);
|
|
146
|
+
background: var(--color-surface);
|
|
147
|
+
box-shadow: inset 0 0 0 var(--border-width-thick) var(--color-border);
|
|
148
|
+
}
|
|
149
|
+
.phase-timeline-item-closed .phase-timeline-node {
|
|
150
|
+
background: var(--color-accent);
|
|
151
|
+
box-shadow: inset 0 0 0 var(--border-width-thick) var(--color-accent);
|
|
152
|
+
}
|
|
153
|
+
.phase-timeline-item-open .phase-timeline-node {
|
|
154
|
+
background: var(--color-surface);
|
|
155
|
+
box-shadow:
|
|
156
|
+
inset 0 0 0 var(--border-width-thick) var(--color-accent),
|
|
157
|
+
0 0 0 var(--border-width-thick) var(--color-accent-subtle);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.phase-timeline-body {
|
|
161
|
+
padding-top: calc((var(--space-md) - 1em) / 2);
|
|
162
|
+
min-width: 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.phase-timeline-label {
|
|
166
|
+
font-family: var(--type-label-font);
|
|
167
|
+
font-size: var(--type-label-size);
|
|
168
|
+
color: var(--color-text-secondary);
|
|
169
|
+
}
|
|
170
|
+
.phase-timeline-item-open .phase-timeline-label,
|
|
171
|
+
.phase-timeline-item-closed .phase-timeline-label {
|
|
172
|
+
color: var(--color-text);
|
|
173
|
+
}
|
|
174
|
+
.phase-timeline-item-current .phase-timeline-label {
|
|
175
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
176
|
+
color: var(--color-text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* Sighted affordance that the open phase is actionable now. */
|
|
180
|
+
.phase-timeline-badge {
|
|
181
|
+
display: inline-block;
|
|
182
|
+
margin-inline-start: var(--space-2xs);
|
|
183
|
+
padding: var(--space-3xs, 0.125rem) var(--space-2xs);
|
|
184
|
+
border-radius: var(--radius-sm);
|
|
185
|
+
background: var(--color-accent-subtle);
|
|
186
|
+
color: var(--color-accent);
|
|
187
|
+
font-family: var(--type-caption-font);
|
|
188
|
+
font-size: var(--type-caption-size);
|
|
189
|
+
font-weight: var(--raw-font-weight-semibold);
|
|
190
|
+
vertical-align: middle;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* State text is for assistive tech only — the node + badge carry it sighted. */
|
|
194
|
+
.phase-timeline-state {
|
|
195
|
+
position: absolute;
|
|
196
|
+
width: 1px;
|
|
197
|
+
height: 1px;
|
|
198
|
+
margin: -1px;
|
|
199
|
+
padding: 0;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
clip: rect(0, 0, 0, 0);
|
|
202
|
+
white-space: nowrap;
|
|
203
|
+
border: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.phase-timeline-window {
|
|
207
|
+
display: block;
|
|
208
|
+
margin-top: var(--space-2xs);
|
|
209
|
+
font-family: var(--type-caption-font);
|
|
210
|
+
font-size: var(--type-caption-size);
|
|
211
|
+
color: var(--color-text-muted);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.phase-timeline-desc {
|
|
215
|
+
margin: var(--space-2xs) 0 0;
|
|
216
|
+
font-family: var(--type-body-sm-font);
|
|
217
|
+
font-size: var(--type-body-sm-size);
|
|
218
|
+
color: var(--color-text-secondary);
|
|
219
|
+
}
|
|
220
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export default PhaseTimeline;
|
|
2
|
+
type PhaseTimeline = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* PhaseTimeline
|
|
8
|
+
*
|
|
9
|
+
* Scheduled-phase timeline for the citizen portal — the spine of a phase-driven
|
|
10
|
+
* process (participatory budgeting: submission → analysis → voting → execution;
|
|
11
|
+
* but equally an SLA cycle, a maintenance plan, or an energy-tariff window).
|
|
12
|
+
*
|
|
13
|
+
* Distinct from StatusTimeline: StatusTimeline tracks a single item's PROGRESS
|
|
14
|
+
* (submitted → resolved). PhaseTimeline shows a SCHEDULE — each phase carries a
|
|
15
|
+
* date window and a derived state (closed / open / upcoming), and one phase is
|
|
16
|
+
* marked the authoritative current phase. The state is computed by the caller
|
|
17
|
+
* from the window dates against the request clock so the widget itself is
|
|
18
|
+
* clock-free (SSR-stable, reduced-motion-safe — no animation).
|
|
19
|
+
*
|
|
20
|
+
* Renders an `<ol>` so order and position are structural; the current phase
|
|
21
|
+
* carries `aria-current="step"`, and each phase's state is announced via a
|
|
22
|
+
* visually-hidden label (pass `stateLabels` to localise — the portal feeds
|
|
23
|
+
* Wuchale strings here).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <PhaseTimeline
|
|
27
|
+
* label="Participatory budgeting calendar"
|
|
28
|
+
* phases={[
|
|
29
|
+
* { label: "Proposals", status: "closed", window: "1–31 Mar" },
|
|
30
|
+
* { label: "Analysis", status: "closed", window: "1–15 Apr" },
|
|
31
|
+
* { label: "Voting", status: "open", current: true, window: "16–30 Apr", description: "Vote now" },
|
|
32
|
+
* { label: "Execution", status: "upcoming", window: "from 1 Jun" },
|
|
33
|
+
* ]}
|
|
34
|
+
* />
|
|
35
|
+
*
|
|
36
|
+
* @example Localised state labels (portal i18n)
|
|
37
|
+
* <PhaseTimeline phases={phases} stateLabels={{ closed: "Encerrada", open: "A decorrer", upcoming: "Brevemente" }} openBadge="A decorrer" />
|
|
38
|
+
*/
|
|
39
|
+
declare const PhaseTimeline: import("svelte").Component<{
|
|
40
|
+
phases?: any[];
|
|
41
|
+
label?: string;
|
|
42
|
+
stateLabels?: Record<string, any>;
|
|
43
|
+
openBadge?: string;
|
|
44
|
+
class?: string;
|
|
45
|
+
} & Record<string, any>, {}, "">;
|
|
46
|
+
type $$ComponentProps = {
|
|
47
|
+
phases?: any[];
|
|
48
|
+
label?: string;
|
|
49
|
+
stateLabels?: Record<string, any>;
|
|
50
|
+
openBadge?: string;
|
|
51
|
+
class?: string;
|
|
52
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component RecordList
|
|
3
|
+
|
|
4
|
+
A typed list of child records rendered from FIELD CONFIG — the generalisation
|
|
5
|
+
of a comment list into any detail-page child collection (reviews, pareceres,
|
|
6
|
+
occurrence updates, maintenance logs). Vertical-agnostic: `items` is the child
|
|
7
|
+
rows, `fields` declares which keys to show and how.
|
|
8
|
+
|
|
9
|
+
Layout convention (so one component serves every collection): the FIRST field
|
|
10
|
+
is the record's primary line; the rest render as a muted meta line, separated
|
|
11
|
+
by middots. A field may `format` as a date (locale-formatted) or `badge`
|
|
12
|
+
(rendered as a Badge). Soft-empty: no items → nothing; an absent field value
|
|
13
|
+
is skipped (no dangling separators).
|
|
14
|
+
|
|
15
|
+
Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
|
|
16
|
+
|
|
17
|
+
@example
|
|
18
|
+
<RecordList
|
|
19
|
+
items={updates}
|
|
20
|
+
fields={[
|
|
21
|
+
{ key: 'note', label: 'Update' },
|
|
22
|
+
{ key: 'author' },
|
|
23
|
+
{ key: 'created_at', format: 'date' },
|
|
24
|
+
{ key: 'status', format: 'badge' },
|
|
25
|
+
]}
|
|
26
|
+
locale="pt"
|
|
27
|
+
/>
|
|
28
|
+
-->
|
|
29
|
+
<script module>
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ key: string, label?: string, format?: 'text' | 'date' | 'badge' }} RecordField
|
|
32
|
+
*/
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import List from './List.svelte';
|
|
37
|
+
import ListItem from './ListItem.svelte';
|
|
38
|
+
import Badge from './Badge.svelte';
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
/** @type {Record<string, unknown>[]} The child records to render. */
|
|
42
|
+
items = [],
|
|
43
|
+
/** @type {RecordField[]} The columns; fields[0] is the primary line. */
|
|
44
|
+
fields = [],
|
|
45
|
+
/** @type {string} BCP-47 locale for date formatting. */
|
|
46
|
+
locale = 'en',
|
|
47
|
+
/** @type {string} */
|
|
48
|
+
class: className = '',
|
|
49
|
+
...rest
|
|
50
|
+
} = $props();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} value
|
|
54
|
+
* @param {RecordField} field
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function display(value, field) {
|
|
58
|
+
if (value === null || value === undefined || value === '') return '';
|
|
59
|
+
if (field.format === 'date') {
|
|
60
|
+
const d = new Date(String(value));
|
|
61
|
+
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(locale);
|
|
62
|
+
}
|
|
63
|
+
return String(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The non-empty meta fields (everything after the primary) for one record —
|
|
68
|
+
* precomputed so the template never emits a leading/dangling separator.
|
|
69
|
+
* @param {Record<string, unknown>} item
|
|
70
|
+
* @returns {{ field: RecordField, value: unknown }[]}
|
|
71
|
+
*/
|
|
72
|
+
function metaParts(item) {
|
|
73
|
+
return fields
|
|
74
|
+
.slice(1)
|
|
75
|
+
.map((field) => ({ field, value: item[field.key] }))
|
|
76
|
+
.filter((p) => p.value !== null && p.value !== undefined && p.value !== '');
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
{#if items.length > 0 && fields.length > 0}
|
|
81
|
+
<List class={className} {...rest}>
|
|
82
|
+
{#each items as item, i (item.id ?? i)}
|
|
83
|
+
{@const primary = fields[0]}
|
|
84
|
+
{@const primaryValue = item[primary.key]}
|
|
85
|
+
{@const meta = metaParts(item)}
|
|
86
|
+
<ListItem>
|
|
87
|
+
{#snippet leading()}
|
|
88
|
+
{#if primaryValue !== null && primaryValue !== undefined && primaryValue !== ''}
|
|
89
|
+
<span class="record-primary">
|
|
90
|
+
{#if primary.format === 'badge'}
|
|
91
|
+
<Badge>{String(primaryValue)}</Badge>
|
|
92
|
+
{:else}
|
|
93
|
+
{display(primaryValue, primary)}
|
|
94
|
+
{/if}
|
|
95
|
+
</span>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if meta.length > 0}
|
|
98
|
+
<span class="record-meta">
|
|
99
|
+
{#each meta as part, mi (part.field.key)}
|
|
100
|
+
{#if mi > 0}<span class="record-sep" aria-hidden="true">·</span>{/if}
|
|
101
|
+
{#if part.field.format === 'badge'}
|
|
102
|
+
<Badge>{String(part.value)}</Badge>
|
|
103
|
+
{:else}<span>{display(part.value, part.field)}</span>{/if}
|
|
104
|
+
{/each}
|
|
105
|
+
</span>
|
|
106
|
+
{/if}
|
|
107
|
+
{/snippet}
|
|
108
|
+
</ListItem>
|
|
109
|
+
{/each}
|
|
110
|
+
</List>
|
|
111
|
+
{/if}
|
|
112
|
+
|
|
113
|
+
<style>
|
|
114
|
+
.record-primary {
|
|
115
|
+
display: block;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.record-meta {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: var(--space-2xs);
|
|
123
|
+
margin-top: var(--space-2xs);
|
|
124
|
+
font-size: var(--type-body-sm-size);
|
|
125
|
+
color: var(--color-text-secondary);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.record-sep {
|
|
129
|
+
color: var(--color-text-secondary);
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export default RecordList;
|
|
2
|
+
export type RecordField = {
|
|
3
|
+
key: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
format?: "text" | "date" | "badge";
|
|
6
|
+
};
|
|
7
|
+
type RecordList = {
|
|
8
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
9
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* RecordList
|
|
13
|
+
*
|
|
14
|
+
* A typed list of child records rendered from FIELD CONFIG — the generalisation
|
|
15
|
+
* of a comment list into any detail-page child collection (reviews, pareceres,
|
|
16
|
+
* occurrence updates, maintenance logs). Vertical-agnostic: `items` is the child
|
|
17
|
+
* rows, `fields` declares which keys to show and how.
|
|
18
|
+
*
|
|
19
|
+
* Layout convention (so one component serves every collection): the FIRST field
|
|
20
|
+
* is the record's primary line; the rest render as a muted meta line, separated
|
|
21
|
+
* by middots. A field may `format` as a date (locale-formatted) or `badge`
|
|
22
|
+
* (rendered as a Badge). Soft-empty: no items → nothing; an absent field value
|
|
23
|
+
* is skipped (no dangling separators).
|
|
24
|
+
*
|
|
25
|
+
* Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <RecordList
|
|
29
|
+
* items={updates}
|
|
30
|
+
* fields={[
|
|
31
|
+
* { key: 'note', label: 'Update' },
|
|
32
|
+
* { key: 'author' },
|
|
33
|
+
* { key: 'created_at', format: 'date' },
|
|
34
|
+
* { key: 'status', format: 'badge' },
|
|
35
|
+
* ]}
|
|
36
|
+
* locale="pt"
|
|
37
|
+
* />
|
|
38
|
+
*/
|
|
39
|
+
declare const RecordList: import("svelte").Component<{
|
|
40
|
+
items?: any[];
|
|
41
|
+
fields?: any[];
|
|
42
|
+
locale?: string;
|
|
43
|
+
class?: string;
|
|
44
|
+
} & Record<string, any>, {}, "">;
|
|
45
|
+
type $$ComponentProps = {
|
|
46
|
+
items?: any[];
|
|
47
|
+
fields?: any[];
|
|
48
|
+
locale?: string;
|
|
49
|
+
class?: string;
|
|
50
|
+
} & Record<string, any>;
|
package/components/index.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export { default as TabList } from "./TabList.svelte";
|
|
|
51
51
|
export { default as Tab } from "./Tab.svelte";
|
|
52
52
|
export { default as TabPanel } from "./TabPanel.svelte";
|
|
53
53
|
export { default as FeedView } from "./FeedView.svelte";
|
|
54
|
+
export { default as ActionPanel } from "./ActionPanel.svelte";
|
|
55
|
+
export { default as RecordList } from "./RecordList.svelte";
|
|
54
56
|
export { default as Alert } from "./Alert.svelte";
|
|
55
57
|
export { default as Toast } from "./Toast.svelte";
|
|
56
58
|
export { default as ToastManager } from "./ToastManager.svelte";
|
package/components/index.js
CHANGED
|
@@ -77,6 +77,13 @@ export { default as Tab } from "./Tab.svelte";
|
|
|
77
77
|
export { default as TabPanel } from "./TabPanel.svelte";
|
|
78
78
|
export { default as FeedView } from "./FeedView.svelte";
|
|
79
79
|
|
|
80
|
+
// Entity ficha (#105 Phase 2)
|
|
81
|
+
export { default as ActionPanel } from "./ActionPanel.svelte";
|
|
82
|
+
export { default as RecordList } from "./RecordList.svelte";
|
|
83
|
+
|
|
84
|
+
// Process awareness (#105 Phase 3)
|
|
85
|
+
export { default as PhaseTimeline } from "./PhaseTimeline.svelte";
|
|
86
|
+
|
|
80
87
|
// Feedback
|
|
81
88
|
export { default as Alert } from "./Alert.svelte";
|
|
82
89
|
export { default as Toast } from "./Toast.svelte";
|