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

454 lines
16 KiB
Svelte

<script lang="ts">
// The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
// coup d'œil »): each section folded to one line — its name, how many articles are
// proposed, a small water level for each, so one sees where support rises without opening
// anything. A gesture unfolds one, « Tout déplier » all of them; a filter, or a vote
// unfolded by the URL, opens the section that holds it. Inside, one line per article (Yvv,
// 2026-10-30): one with a single version unfolds its vote below it; one with several shows
// the best and unfolds the others. A section not read in the text above is closed here by
// its order.
import type { Snippet } from 'svelte';
import { plain } from '$lib/markdown';
import { needOf, relationOf, sectionLabel, titleOf } from '$lib/labels';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article, type DeepSection } from '$lib/versions';
import Crans from './Crans.svelte';
import Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
sections,
vote,
reordering = null,
all = false,
ontoggle,
fold,
order,
onsection,
}: {
/** The articles with open proposals, by section, each with its order when it closes here. */
sections: DeepSection[];
/** The version whose vote is unfolded. */
vote: string | null;
/** The order module being reordered: its section stays open. */
reordering?: string | null;
/** Every section open (a filter is on). */
all?: boolean;
ontoggle: (unit: Unit, p: Proposal) => void;
/** The vote of a version, and the versions already listed beside it (not named again inside). */
fold: Snippet<[Unit, Proposal, string[]]>;
/** The order line of a section, at its end. */
order: Snippet<[Unit]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: string) => void;
} = $props();
const keyOf = (s: DeepSection) => s.key ?? '';
// Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
let unfolded = $state<Record<string, boolean>>({});
let versionsOpen = $state<Record<string, boolean>>({});
const holds = (s: DeepSection) =>
s.articles.some((a) => a.versions.some((p) => p.id === vote)) ||
(!!s.order && (s.order.id === reordering || [s.order.inForce, ...s.order.proposals].some((p) => p?.id === vote)));
const isOpen = (s: DeepSection) => all || (unfolded[keyOf(s)] ?? holds(s));
const everyOpen = $derived(sections.every((s) => isOpen(s)));
const toggle = (s: DeepSection) => (unfolded[keyOf(s)] = !isOpen(s));
function setAll(open: boolean) {
unfolded = Object.fromEntries(sections.map((s) => [keyOf(s), open]));
}
const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
function headline(a: Article): string {
const text = a.versions.find((p) => p.kind === 'text');
const best = a.versions[0];
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
}
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
function labelOf(p: Proposal, a: Article): string {
if (p.kind === 'inertia' || p.kind === 'remove') return titleOf(p, a.unit);
if (p.label) return t('deep.variant', { label: p.label });
if (p.author?.name) return t('panel.by', { name: p.author.name });
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
return t('deep.version', { n: arrival + 1 });
}
/** The excerpt of each text version, from where it parts ways with its siblings. */
function toldApart(a: Article): Map<string, string> {
const textual = a.versions.filter((p) => p.kind === 'text');
const told = excerpts(textual.map((p) => plain(p.body)));
return new Map([
...a.versions.map((p): [string, string] => [p.id, p.kind === 'text' ? plain(p.body) : '']),
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
]);
}
</script>
{#snippet need(p: Proposal)}
{@const n = needOf(p)}
{#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
{/snippet}
{#snippet marks(p: Proposal)}
{#if p.seedProposal}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet}
{#if sections.length && !all}
<div class="all">
<button type="button" class="linkish" onclick={() => setAll(!everyOpen)}>{t(everyOpen ? 'deep.fold' : 'deep.unfold')}</button>
</div>
{/if}
<ul class="deep">
{#each sections as s, i (keyOf(s))}
{@const open = isOpen(s)}
{@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))}
<li class="section" class:open>
<div class="head">
<button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
<span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
<span class="name">{label}</span>
{#if s.articles.length}
<span class="count">{i18n.plural('deep.section.count', s.articles.length)}</span>
<span class="waters" aria-hidden="true">
{#each s.articles as a (a.unit.id)}
{@const best = a.versions[0]}
{#if best}<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} size="mini" />{/if}
{/each}
</span>
{/if}
</button>
{#if onsection && s.key && s.articles.length}
{@const key = s.key}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</div>
{#if open}
<div class="body" id="deep-sec-{i}">
{#if s.order?.id !== reordering}
<ul class="rows">
{#each s.articles as a (a.unit.id)}
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if best && a.versions.length === 1}
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{@render marks(best)}
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best, [])}</div>{/if}
</li>
{:else if best}
{@const vopen = isVersionsOpen(a)}
{@const title = headline(a)}
{@const told = toldApart(a)}
<li class="article" class:open={vopen}>
<button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{title}</span>
{#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</span>{/if}
<span class="meta">
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
</span>
</span>
<span class="chevron"><Icon name="chevron" size={18} /></span>
</button>
<ul class="versions" id="versions-{unit.id}" hidden={!vopen}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{@render need(p)}
{@render marks(p)}
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p, a.versions.map((v) => v.id))}{/if}
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
{/if}
{#if s.order}{@render order(s.order)}{/if}
</div>
{/if}
</li>
{:else}
<li class="faint none">{t('deep.none')}</li>
{/each}
</ul>
<style>
.all {
display: flex;
justify-content: flex-end;
margin-bottom: 0.2rem;
}
.linkish {
border: 0;
background: none;
padding: 0.3rem 0;
min-height: 2.25rem;
color: var(--water);
font-weight: 750;
font-size: 0.86rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.deep,
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.section {
margin: 0 -1rem;
border-radius: var(--r-card);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.6rem;
}
.toggle {
flex: 1 1 16rem;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem 0.7rem;
min-height: 3rem;
padding: 0.55rem 1rem;
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.toggle:hover {
background: var(--surface);
}
.name {
font-family: var(--font-text);
font-style: italic;
font-size: 1.12rem;
color: var(--ink);
}
.count {
flex: none;
font-size: 0.8rem;
font-weight: 750;
color: var(--water);
white-space: nowrap;
}
.waters {
display: inline-flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 3px;
min-width: 0;
}
.head .chevron {
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.section.open .head .chevron {
transform: rotate(90deg);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
margin: 0 0.6rem 0 auto;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.body {
display: grid;
gap: 0.25rem;
padding: 0 0 0.9rem;
animation: unfold 0.2s var(--ease);
}
.body > :global(.order-line) {
margin: 0.3rem 0 0.4rem;
}
.article {
border-radius: var(--r-card);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.article.open {
background: var(--surface);
box-shadow: var(--shadow);
}
.row {
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
padding: 0.85rem 1rem;
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.article:not(.open) .row:hover,
.row.current {
background: var(--surface);
box-shadow: var(--shadow);
}
.main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.title {
font-weight: 780;
}
.excerpt {
font-family: var(--font-text);
color: var(--ink-soft);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.8rem;
align-items: center;
font-size: 0.8rem;
font-weight: 700;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.need {
color: var(--ink);
}
.row .chevron {
flex: none;
margin-left: auto;
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.article.open .row .chevron {
transform: rotate(90deg);
}
.versions {
list-style: none;
margin: 0;
padding: 0 0.6rem 0.7rem 1.6rem;
display: grid;
gap: 0.15rem;
}
.versions[hidden] {
display: none;
}
.version {
display: flex;
gap: 0.85rem;
align-items: center;
width: 100%;
padding: 0.6rem 0.8rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.version:hover,
.version.current {
background: var(--surface-2);
}
.label {
font-weight: 750;
font-size: 0.92rem;
}
.other {
font-weight: 600;
color: var(--ink-soft);
}
.fold {
padding: 0 1rem 1rem;
}
.none {
padding: 0.8rem 0;
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (max-width: 560px) {
.versions {
padding-left: 0.6rem;
}
}
@media (max-width: 899px) {
.vote-section,
.linkish {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.body {
animation: none;
}
.head .chevron,
.row .chevron,
.toggle,
.row,
.version,
.article {
transition: none;
}
}
</style>