forked from yvv/decision
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
9.2 KiB
Svelte
274 lines
9.2 KiB
Svelte
<script lang="ts">
|
|
// The side panel: the basket of the vote, the versions of an article, a new
|
|
// proposal, the details of a version, the settings of the formula. The vote of
|
|
// one version unfolds under its article, not here.
|
|
import type { DocState, PanelState } from '$lib/doc.svelte';
|
|
import { t } from '$lib/i18n/index.svelte';
|
|
import { relationOf, titleOf } from '$lib/labels';
|
|
import { answerPanel } from '$lib/objections';
|
|
import type { BasketState } from '$lib/picks.svelte';
|
|
import { session } from '$lib/session.svelte';
|
|
import type { Lang, Proposal } from '$lib/types';
|
|
import type { Line } from '$lib/vote';
|
|
import BallotPanel from './BallotPanel.svelte';
|
|
import FormulaSettings from './FormulaSettings.svelte';
|
|
import Icon from './Icon.svelte';
|
|
import OrderEditor from './OrderEditor.svelte';
|
|
import ProposeView from './ProposeView.svelte';
|
|
import TitlePropose from './TitlePropose.svelte';
|
|
import VersionDetails from './VersionDetails.svelte';
|
|
import WaterLevel from './WaterLevel.svelte';
|
|
|
|
let {
|
|
doc,
|
|
panel,
|
|
lang,
|
|
basket,
|
|
vote,
|
|
onchange,
|
|
onclose,
|
|
onvote,
|
|
onsign,
|
|
}: {
|
|
doc: DocState;
|
|
panel: PanelState;
|
|
lang: Lang;
|
|
basket: BasketState;
|
|
/** The version whose vote is unfolded under its article. */
|
|
vote: string | null;
|
|
onchange: (p: PanelState) => void;
|
|
onclose: () => void;
|
|
/** Unfolds the vote of a version under its article; `close` closes the panel. */
|
|
onvote: (proposalId: string, close: boolean) => void;
|
|
onsign: (lines: Line[]) => void;
|
|
} = $props();
|
|
|
|
const unit = $derived(doc.unit(panel.unitId));
|
|
const versions = $derived(
|
|
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
|
|
);
|
|
// A module (ADR 0009) is proposed with its own gesture: another order, another title.
|
|
const proposeLabel = $derived(unit?.kind === 'order' ? t('order.propose') : unit?.kind === 'title' ? t('title.propose') : t('doc.action.propose'));
|
|
const nameOf = (id: string) => doc.articleTitle(id, lang, doc.view?.doc.sourceLang ?? 'fr');
|
|
/** A version in the list: an order reads as its articles, in its order. */
|
|
const nameOfVersion = (v: Proposal) => (v.kind === 'order' && v.order ? v.order.ids.map(nameOf).join(' · ') : titleOf(v, unit));
|
|
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
|
|
const tabs = $derived(
|
|
(
|
|
[
|
|
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
|
|
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
|
|
['propose', t('panel.propose'), true],
|
|
// A version: reached from its vote, or from the list of versions.
|
|
['details', t('panel.details'), !!panel.proposalId],
|
|
// Reached from the head of the document or from a vote; shown while it is open.
|
|
['settings', t('panel.settings'), panel.tab === 'settings'],
|
|
] as const
|
|
).filter(([, , show]) => show),
|
|
);
|
|
let body: HTMLElement | undefined = $state();
|
|
let tablist: HTMLElement | undefined = $state();
|
|
|
|
$effect(() => {
|
|
void panel.tab;
|
|
void panel.proposalId;
|
|
body?.scrollTo({ top: 0 });
|
|
});
|
|
|
|
// On a narrow sheet the tabs scroll: the open one stays in sight.
|
|
$effect(() => {
|
|
void panel.tab;
|
|
tablist?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
});
|
|
</script>
|
|
|
|
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
|
|
<div class="bar">
|
|
<div class="tabs" role="tablist" bind:this={tablist}>
|
|
{#each tabs as [key, label] (key)}
|
|
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
|
|
{/each}
|
|
</div>
|
|
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
|
</div>
|
|
|
|
<div class="body" bind:this={body}>
|
|
{#if panel.tab === 'basket'}
|
|
<BallotPanel {doc} {basket} section={panel.section ?? null} {onsign} onread={(id) => onvote(id, false)} />
|
|
{:else if panel.tab === 'details' && voteId}
|
|
<VersionDetails
|
|
{doc}
|
|
proposalId={voteId}
|
|
{lang}
|
|
edit={!!panel.edit}
|
|
onvote={(id) => onvote(id, true)}
|
|
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })}
|
|
onanswer={(o) => {
|
|
const next = answerPanel(o, session.me?.id ?? null);
|
|
if (next) onchange(next);
|
|
}}
|
|
/>
|
|
{:else if panel.tab === 'proposals' && unit}
|
|
<ul class="versions">
|
|
{#each versions as v (v.id)}
|
|
<li>
|
|
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
|
|
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
|
<span class="text">
|
|
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
|
<span class="title">{nameOfVersion(v)}</span>
|
|
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{proposeLabel}</button>
|
|
{:else if panel.tab === 'propose' && unit?.kind === 'order'}
|
|
<OrderEditor {doc} {unit} {nameOf} ondone={(id) => onvote(id, true)} oncancel={onclose} />
|
|
{:else if panel.tab === 'propose' && unit?.kind === 'title'}
|
|
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
|
|
{:else if panel.tab === 'propose'}
|
|
<ProposeView
|
|
{doc}
|
|
unitId={panel.unitId}
|
|
draftId={panel.draftId ?? null}
|
|
prefill={panel.prefill ?? null}
|
|
from={panel.objectionId ? (panel.proposalId ?? null) : null}
|
|
objectionId={panel.objectionId ?? null}
|
|
ondone={(r) =>
|
|
r.proposalId && r.submitted
|
|
? onvote(r.proposalId, true)
|
|
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
|
|
/>
|
|
{:else if panel.tab === 'settings'}
|
|
<FormulaSettings {doc} proposalId={voteId} />
|
|
{/if}
|
|
</div>
|
|
</aside>
|
|
|
|
<style>
|
|
.panel {
|
|
position: fixed;
|
|
top: 0.8rem;
|
|
right: 0.8rem;
|
|
bottom: 0.8rem;
|
|
width: min(var(--panel), calc(100vw - 1.6rem));
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 35;
|
|
box-shadow: var(--shadow-strong);
|
|
animation: enter 0.24s var(--ease);
|
|
}
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0.2rem;
|
|
background: var(--surface-2);
|
|
padding: 3px;
|
|
border-radius: var(--r-pill);
|
|
overflow-x: auto;
|
|
}
|
|
.tabs button {
|
|
border: 0;
|
|
background: transparent;
|
|
border-radius: var(--r-pill);
|
|
padding: 0.35rem 0.8rem;
|
|
min-height: 2.1rem;
|
|
font-weight: 750;
|
|
font-size: 0.86rem;
|
|
color: var(--ink-soft);
|
|
white-space: nowrap;
|
|
}
|
|
.tabs button.on {
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.close {
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border: 0;
|
|
border-radius: var(--r-icon);
|
|
background: transparent;
|
|
color: var(--ink-soft);
|
|
}
|
|
.close:hover {
|
|
background: var(--water-wash);
|
|
}
|
|
.body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.6rem 1.2rem 1.6rem;
|
|
overscroll-behavior: contain;
|
|
}
|
|
.versions {
|
|
list-style: none;
|
|
margin: 0 0 1rem;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
}
|
|
.version {
|
|
display: flex;
|
|
gap: 0.8rem;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0.7rem 0.8rem;
|
|
border: 0;
|
|
border-radius: var(--r-input);
|
|
background: transparent;
|
|
text-align: left;
|
|
}
|
|
.version:hover,
|
|
.version.current {
|
|
background: var(--water-wash);
|
|
}
|
|
.text {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
}
|
|
.title {
|
|
font-weight: 750;
|
|
}
|
|
.small {
|
|
font-size: 0.82rem;
|
|
}
|
|
.held {
|
|
color: var(--ochre);
|
|
}
|
|
@keyframes enter {
|
|
from {
|
|
transform: translateX(16px);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
@media (max-width: 899px) {
|
|
.panel {
|
|
top: auto;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
max-height: 86dvh;
|
|
border-radius: var(--r-card) var(--r-card) 0 0;
|
|
animation-name: sheet;
|
|
}
|
|
@keyframes sheet {
|
|
from {
|
|
transform: translateY(24px);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
}
|
|
</style>
|