Files
decision/web/src/lib/components/Reorder.svelte
T
YvvandClaude Opus 5.5 8528a269fc Lot U3 : l'interface de l'Acte d'engagement (Yvv, 03/11) — avant le texte, hors vote, « Info ouverture doc de réf. » (OpeningInfo, lib/opening.ts) : le paragraphe d'Yvv mot pour mot à la place de doc.empty.seeds (sa première phrase tant qu'aucun article n'est en vigueur, puis « entrent en vigueur » où « y » n'avait plus rien à reprendre), le cran d'ouverture des Propositions d'amorce, du titre, de l'ordre de chaque section et des autres articles (avant tout changement voté) et le quorum de chaque cran, lus dans l'API ; une séparation (un filet de la couleur de la clé de voûte, jamais une bordure) ; plus les titres « Introduction » et « Engagements fondamentaux » dans le texte. Les informations de F1, F1.1, F1.2, N1, N1.1 et O1 là où elles servent : vote.permanent, vote.rival, vote.others.rule, formula.Tprime, formula.round, formula.B.wot, formula.G, settings.share.note, settings.usual.quorum, settings.gradient, order.edit.effect ; les termes définis de T3c (« à la participation habituelle », « quorum de l'article », « part exigée », « repose sur moins de N propositions votées depuis un an »), plus de « comme d'habitude ». « Proposition d'amorce » (Starter proposal, Propuesta de arranque). Le mot d'Yvv pour qui n'est pas invité, à l'accueil sans session (à la place du bouton Ğ1 tant que la connexion Ğ1 n'est pas active) et sur « Se connecter » (signin.g1.pending sans redite). Réordonner : la ligne sous le pointeur lue dans la mise en page (offsetTop), pas là où animate:flip la montre — une poignée tirée sur plusieurs lignes ne revient plus en arrière ; le pointeur capturé par la liste. API : seedFeatured sur chaque version (seul changement sous crates/). C1, C3, C20, C27, C35, C38 suivent les mots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:28:41 +01:00

401 lines
12 KiB
Svelte

<script lang="ts">
// Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
// "poignées" aux modules de la section. Et permette de voter. »): the articles of the
// section fold to their titles, a handle before each. One drags a handle (mouse, pen or
// finger: pointer events), or — without dragging — clicks an article then its new place,
// or uses its arrows; with the keyboard, the up and down arrows move the article that has
// the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the
// vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order
// already proposed is voted, never proposed twice. While the order is the one in force,
// the button stays off and says why, beside it (lot U2, UX-lecteur.md).
import { tick, untrack } from 'svelte';
import { flip } from 'svelte/animate';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { inertiaLevel } from '$lib/labels';
import { moved, place, sameOrder, step } from '$lib/order';
import { toasts } from '$lib/toast.svelte';
import type { Unit } from '$lib/types';
import { errorText } from '$lib/vote';
import Icon from './Icon.svelte';
let {
doc,
unit,
nameOf,
ondone,
oncancel,
}: {
doc: DocState;
/** The order module of the section. */
unit: Unit;
/** The title of an article, in the language it is read in. */
nameOf: (unitId: string) => string;
/** The order proposed, or the same one already proposed: its vote unfolds. */
ondone: (proposalId: string) => void;
oncancel: () => void;
} = $props();
let list = $state<string[]>([]);
let chosen = $state<string | null>(null);
let dragging = $state<string | null>(null);
let said = $state('');
let busy = $state(false);
let error = $state('');
let root: HTMLElement | undefined = $state();
const start = $derived(unit.members ?? []);
const changed = $derived(moved(start, list));
const unchanged = $derived(sameOrder(start, list));
/** The same order, already proposed: one votes it rather than proposing a double. */
const existing = $derived(
unit.proposals.find((p) => p.status === 'open' && p.kind === 'order' && sameOrder(p.order?.ids ?? [], list)),
);
/** An article of the section only proposed, not in force: said as such. */
const proposed = (id: string) => !doc.unit(id)?.inForce;
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
// Started again only for another section: a reload never wipes what is being moved.
$effect(() => {
void unit.id;
untrack(() => {
list = [...(unit.members ?? [])];
chosen = null;
error = '';
});
});
async function focus(id: string) {
await tick();
root?.querySelector<HTMLButtonElement>(`button.item[data-id="${CSS.escape(id)}"]`)?.focus();
}
function tell(id: string) {
said = t('order.edit.moved', { title: nameOf(id), n: list.indexOf(id) + 1, total: list.length });
}
function pick(id: string) {
error = '';
if (chosen === null) {
chosen = id;
said = t('order.edit.chosen', { title: nameOf(id) });
return;
}
if (chosen === id) {
chosen = null;
said = '';
return;
}
const which = chosen;
list = place(list, which, id);
chosen = null;
tell(which);
void focus(which);
}
function shift(id: string, by: -1 | 1) {
error = '';
list = step(list, id, by);
tell(id);
void focus(id);
}
function key(e: KeyboardEvent, id: string) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
shift(id, e.key === 'ArrowUp' ? -1 : 1);
} else if (e.key === 'Escape' && chosen) {
e.stopPropagation();
chosen = null;
said = '';
}
}
// Dragging by the handle: the article takes the place of the row under the pointer. The pointer
// is captured by the list, which stays put, rather than by the handle of a row the reorder moves
// (a guard: what lot U3 found is the measure of the rows, in `drag`).
function grab(e: PointerEvent, id: string) {
if (busy || e.button !== 0) return;
e.preventDefault();
root?.setPointerCapture(e.pointerId);
dragging = id;
chosen = null;
error = '';
}
function drag(e: PointerEvent) {
const id = dragging;
if (!id || !root) return;
// The rows where the layout puts them, not where their animation shows them (`animate:flip`
// moves them by a transform): a row sliding under the pointer would send the article back.
const y = e.clientY - root.getBoundingClientRect().top;
for (const row of root.querySelectorAll<HTMLElement>('li[data-id]')) {
const top = row.offsetTop - root.offsetTop;
if (y < top || y >= top + row.offsetHeight) continue;
const target = row.dataset.id;
if (target && target !== id) list = place(list, id, target);
return;
}
}
function drop() {
const id = dragging;
if (!id) return;
dragging = null;
tell(id);
void focus(id);
}
async function submit() {
if (unchanged) return;
if (existing) {
ondone(existing.id);
return;
}
busy = true;
error = '';
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/order`, { order: list });
await doc.reload();
toasts.show(t('order.submitted'));
ondone(r.id);
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
</script>
<div class="reorder">
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
<ol
class="list"
aria-label={t('order.edit.label')}
aria-describedby="order-help-{unit.id}"
bind:this={root}
onpointermove={drag}
onpointerup={drop}
onpointercancel={drop}
onlostpointercapture={drop}
>
{#each list as id, i (id)}
{@const name = nameOf(id)}
<li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
<span class="grip" aria-hidden="true" onpointerdown={(e) => grab(e, id)}>
<Icon name="grip" size={18} />
</span>
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} disabled={busy} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
<span class="n" aria-hidden="true">{i + 1}</span>
<span class="what">
<span class="name">{name}</span>
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
</span>
</button>
<span class="arrows">
<button type="button" class="arrow up" aria-label={t('order.edit.up', { title: name })} disabled={i === 0 || busy} onclick={() => shift(id, -1)}><Icon name="chevron" size={16} /></button>
<button type="button" class="arrow down" aria-label={t('order.edit.down', { title: name })} disabled={i === list.length - 1 || busy} onclick={() => shift(id, 1)}><Icon name="chevron" size={16} /></button>
</span>
</li>
{/each}
</ol>
<p class="visually-hidden" aria-live="polite">{said}</p>
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('order.edit.effect')} {t('module.order.rule')}</p>
{#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions">
{#if unchanged}<p class="why" id="order-why-{unit.id}">{t('order.edit.unchanged')}</p>{/if}
<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>
<button
class="btn primary small"
type="button"
disabled={busy || unchanged}
aria-describedby={unchanged ? `order-why-${unit.id}` : undefined}
onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button
>
</div>
</div>
<style>
.reorder {
display: grid;
gap: 0.7rem;
min-width: 0;
animation: unfold 0.2s var(--ease);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.list li {
display: flex;
align-items: center;
gap: 0.2rem;
border-radius: var(--r-input);
background: var(--surface);
box-shadow: var(--shadow);
transition: background-color 0.15s var(--ease);
}
.list li.moved {
background: color-mix(in srgb, var(--surface) 55%, var(--moss-wash));
}
.list li.chosen,
.list li.dragging {
background: var(--water);
color: var(--on-water);
}
.list li.dragging {
box-shadow: var(--shadow-strong);
}
.grip {
display: grid;
place-items: center;
flex: none;
width: 2.4rem;
min-height: 2.75rem;
align-self: stretch;
color: var(--ink-faint);
cursor: grab;
touch-action: none;
border-radius: var(--r-input) 0 0 var(--r-input);
}
.grip:hover {
color: var(--water);
}
.dragging .grip,
.chosen .grip {
color: inherit;
cursor: grabbing;
}
.item {
flex: 1;
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
min-height: 2.75rem;
padding: 0.45rem 0.3rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.n {
flex: none;
min-width: 1.2rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--water);
}
.chosen .n,
.dragging .n {
color: inherit;
}
.what {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.6rem;
min-width: 0;
}
.name {
font-family: var(--font-text);
font-weight: 600;
overflow-wrap: anywhere;
}
.chosen .tag,
.dragging .tag {
background: color-mix(in srgb, var(--on-water) 22%, transparent);
color: inherit;
}
.arrows {
display: flex;
flex: none;
gap: 0.1rem;
padding-right: 0.25rem;
}
.arrow {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: inherit;
}
.arrow:hover:not(:disabled) {
background: var(--water-wash);
}
.arrow:disabled {
opacity: 0.3;
}
.arrow.up :global(svg) {
transform: rotate(-90deg);
}
.arrow.down :global(svg) {
transform: rotate(90deg);
}
.small {
font-size: 0.84rem;
}
.note {
font-size: 0.88rem;
font-weight: 700;
color: var(--water);
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
/* Why the button is off, on its left: read before one reaches for it. */
.why {
flex: 1 1 14rem;
margin: 0;
font-size: 0.84rem;
font-weight: 650;
color: var(--ink-soft);
}
/* On a phone: every target is big enough for a finger. */
@media (max-width: 899px) {
.arrow {
width: 2.75rem;
height: 2.75rem;
}
.actions .btn {
min-height: 2.75rem;
}
/* Its own line, above the two buttons kept together. */
.why {
flex-basis: 100%;
}
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.reorder {
animation: none;
}
.list li {
transition: none;
}
}
</style>