Files
decision/web/src/lib/components/UnitBlock.svelte
T

196 lines
5.9 KiB
Svelte

<script lang="ts">
// An article as the text reads it: its title, its body, its state; two gestures —
// « Le vote » unfolds under it (its other versions inside), « Proposer une modification »
// opens the panel, where one writes (lot U, UX-lecteur.md). A seed proposal (ADR 0011),
// read at the head while nothing is in force, is marked so: never the bare body of a text
// in force.
import type { Snippet } from 'svelte';
import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { needOf } from '$lib/labels';
import type { Proposal } from '$lib/types';
import Crans from './Crans.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
text,
mark = 'unchanged',
seedMinimal = false,
seed = null,
enteredAt = null,
rivals = 0,
selected = false,
actions = true,
voteFor = null,
voteOpen = false,
votes = 0,
proposeLabel,
fold,
onaction,
}: {
text: Shown;
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
seedMinimal?: boolean;
/** A seed proposal at the head (ADR 0011): how far it is from passing, not in force. */
seed?: Proposal | null;
enteredAt?: string | null;
/** Its other versions open to the vote: said in its state, read in « Le vote ». */
rivals?: number;
selected?: boolean;
actions?: boolean;
/** The version « Le vote » unfolds, and whether it is unfolded. */
voteFor?: string | null;
voteOpen?: boolean;
/** The votes on the version in force, shown next to the seed mark. */
votes?: number;
proposeLabel?: string;
/** The vote, unfolded under the text. */
fold?: Snippet;
onaction: (action: 'vote' | 'propose') => void;
} = $props();
</script>
<article
class="unit {mark}"
class:selected
class:seed={!!seed}
class:unfolded={voteOpen}
>
<header>
{#if seed}<WaterLevel tally={seed.tally} evaluation={seed.evaluation} blocked={seed.blocked} />{/if}
<h2 class="eyebrow">{text.title}</h2>
{#if seed}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
</header>
<Prose text={text.body} />
{#if actions}
<footer class="actions">
<!-- Its vote unfolded says the totals, the « oui » to pass, the inertia and the other
versions: the line does not say them again (lot U2). -->
{#if !(seed && voteOpen)}
<span class="status faint">
{#if seed}
{@const need = needOf(seed)}
{t('tally.short', { for: seed.tally.for, against: seed.tally.against })}{#if need}{' · '}<span title={need.title}>{need.short}</span>{/if}
<span class="crans"><Crans level={seed.votedAtInertia} short /></span>
{:else if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
{#if rivals > 0 && !voteOpen}{' · '}{i18n.plural('doc.others', rivals)}{/if}
</span>
{/if}
<span class="links">
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
</span>
</footer>
{/if}
{#if voteOpen && fold}{@render fold()}{/if}
</article>
<style>
.unit {
position: relative;
padding: 1.1rem 1.3rem 0.9rem;
margin: 0 -1.3rem;
border-radius: var(--r-card);
transition: background-color 0.18s ease;
}
.unit + :global(.unit) {
margin-top: 0.5rem;
}
.unit:hover,
.unit:focus-within,
.unit.selected,
.unit.unfolded {
background: var(--surface);
box-shadow: var(--shadow);
}
/* A seed proposal: the water of a vote under way, never the bare page of a text in force. */
.unit.seed {
background: color-mix(in srgb, var(--water-wash) 55%, transparent);
}
.unit.seed :global(.prose) {
color: var(--ink-soft);
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.removed :global(.prose) {
text-decoration: line-through;
text-decoration-thickness: 1px;
color: var(--ink-faint);
}
.added,
.replaced {
background: var(--moss-wash);
}
.crans {
margin-left: 0.6rem;
}
/* The gestures keep their place whether the line of state is there or not (it steps aside
while the vote is unfolded, lot U2): on the right on a desktop, on the left on a phone. */
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.4rem 1rem;
margin-top: 0.7rem;
min-height: 2.25rem;
font-size: 0.84rem;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.unit:hover .links,
.unit:focus-within .links,
.unit.selected .links,
.unit.unfolded .links {
opacity: 1;
}
.links button {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
padding: 0.35rem 0.6rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.links button:hover,
.links button.on {
background: var(--water-wash);
}
@media (hover: none) {
.links {
opacity: 1;
}
}
@media (min-width: 900px) {
.links {
margin-left: auto;
}
}
@media (max-width: 899px) {
.links button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.unit,
.links {
transition: none;
}
}
</style>